24 Extension Case Study: Springs, Part 4

In the last chapter we exhausted our options for our spring geom safe for delving into the development of a new grid grob. grid is the underlying graphic system that ggplot2 builds upon and while much can be achieved by ignoring grid entirely, there are situations where it is impossible to achieve what you want without going down to the grid level. There are especially two situations that warrant the need for using grid directly when developing ggplot2 extensions:

  1. You need to create graphical objects that are positioned correctly on the coordinate system, but where some part of their appearance has a fixed absolute size. In our case this would be the spring correctly going between two points in the plot, but the diameter being defined in cm instead of relative to the coordinate system.

  2. You need graphical objects that are updated during resizing. This could e.g. be the position of labels such as in the ggrepel package or the geom_mark_*() geoms in ggforce.

Before we begin developing the new version of our geom it will be good to have at least a cursory understanding of the key concepts in grid:

24.1 Grid in 5 minutes

grid is one of two graphic systems distributed with R (the other being base graphics). It works quite differently from base graphics in that graphics is build up by creating a nested description of the graphic, rather than directly sending drawing instructions to the device. The benefit of this is that the description of the graphic exists outside of the graphic device as an R object that can be passed around, modified, etc. and that parts of the graphical description can reference other parts (e.g., make this rectangle as wide as this string of text). Once the description is plotted grid will react to resizing of the graphic device and rerender the current plot. This means that grid graphic is able to be dimension aware and is the reason why it is possible to resize a ggplot2 plot and keep the margins fixed while the plotting region expands.

While a thorough review of how grid works is beyond the scope of this chapter, there is a couple of concepts and classes that is required to understand for ggplot2 extension developers:

24.1.1 Grobs

Grobs (graphic objects) are the atomic representations of graphical elements in grid. As such they can be likened to the different elements that exists in the SVG definition such as <circle/> and <text/>. In contrast to SVG, grobs are vectorised so that e.g. a point grob can represent multiple points. Grid comes with a range of build-in grobs that cover the vast majority of use cases as well as the means to combine multiple grobs into a gTree() which groups them together into what is conceptually a single grob.

It is possible to create new grob classes with the grob() or gTree() constructors. By defining makeContext() and makeContent() methods for your class you can control how it behaves during rendering. makeContext() is called when the parent grob is rendered and allows you to control the viewport of the grob (see below). makeContent() is called everytime the drawing region is resized and allows you to customise the look based on the size or other aspect.

24.1.2 Viewports

Viewports are plotting region definitions. They have their own coordinate system that grobs in the viewport will be positioned according to. They may also define a tabular grid that child viewports can occupy. A grob can have it’s own viewport or inherit the viewport of it’s parent. While we will not need to modify the viewport of our spring grob (once developed), viewports is an important concept that opens the door for many possibilities.

24.1.3 Units

grid has a very flexible way of specifying positions and dimensions of grobs and viewports. All of this is encapsulated in the unit() class which support both absolute units (e.g. centimeters, inches, and points), relative units (e.g. npc which scales the viewport size between 0 and 1), and units based on other grobs (e.g. grobwidth). units support arithmetic operations and are only resolved at draw time (where all units are absolute) so it is possible to combine all types of units, e.g. unit(0.5, 'npc') + unit(1, 'cm') defines a point one centimeter to the left of the center of the current viewport.

24.1.4 Example

With our very cursory introduction, let us now see an example grob implementation. The code below will create a grob that appears as a square if the size of it is above 5 cm and a circle if it is below:

library(grid)
surpriseGrob <- function(x, y, size, default.units = "npc", name = NULL, 
                         gp = gpar(), vp = NULL) {
  # Check if input needs to be converted to units
  if (!is.unit(x)) {
    x <- unit(x, default.units)
  }
  if (!is.unit(y)) {
    y <- unit(y, default.units)
  }
  if (!is.unit(size)) {
    size <- unit(size, default.units)
  }
  # Construct our surprise grob subclass as a gTree
  gTree(x = x, y = y, size = size, name = name, gp = gp, vp = vp, cl = "surprise")   
}
makeContent.surprise <- function(x) {
  x_pos <- x$x
  y_pos <- x$y
  size <- convertWidth(x$size, unitTo = "cm", valueOnly = TRUE)
  
  # Figure out if the given sizes are bigger or smaller than 5 cm
  circles <- size < 5
  
  # Create a circle grob for the small ones
  if (any(circles)) {
    circle_grob <- circleGrob(
      x = x_pos[circles], 
      y = y_pos[circles], 
      r = unit(size[circles] / 2, "cm")
    )
  } else {
    circle_grob <- nullGrob()
  }
  
  
  # Create a rect grob for the large ones
  if (any(!circles)) {
    square_grob <- rectGrob(
      x = x_pos[!circles], 
      y = y_pos[!circles], 
      width = unit(size[!circles], "cm"),
      height = unit(size[!circles], "cm")
    )
  } else {
    square_grob <- nullGrob()
  }
  
  
  # Add the circle and rect grob as childrens of our input grob
  setChildren(x, gList(circle_grob, square_grob))
}

# Create an instance of our surprise grob defining to object with different
# sizes
gr <- surpriseGrob(x = c(0.25, 0.75), y = c(0.5, 0.5), size = c(0.1, 0.4))

# Draw it
grid.newpage()
grid.draw(gr)

If you run the code above interactively and resize the plotting window you can see that the two objects will change form based on the size of the plotting window. This is a useless example, of course, but hopefully you can see how this technique can be used to do real work.

24.2 The springGrob

With our new knowledge of the grid system we can now see how we might construct a spring grob that have an absolute diameter. If we wait with the expansion to the spring path until the makeContent() function, and calculate it based on coordinates in absolute units we can make sure that the diameter stays constant during resizing of the plot. With that in mind, we can create our constructor. We model the arguments after segmentsGrob() since we are basically creating modified segments:

springGrob <- function(x0 = unit(0, "npc"), y0 = unit(0, "npc"), 
                       x1 = unit(1, "npc"), y1 = unit(1, "npc"), 
                       diameter = unit(0.1, "npc"), tension = 0.75,
                       n = 50, default.units = "npc", name = NULL, 
                       gp = gpar(), vp = NULL) {
  if (!is.unit(x0)) x0 <- unit(x0, default.units)
  if (!is.unit(x1)) x1 <- unit(x1, default.units)
  if (!is.unit(y0)) y0 <- unit(y0, default.units)
  if (!is.unit(y1)) y1 <- unit(y1, default.units)
  if (!is.unit(diameter)) diameter <- unit(diameter, default.units)
  gTree(x0 = x0, y0 = y0, x1 = x1, y1 = y1, diameter = diameter, 
       tension = tension, n = n, name = name, gp = gp, vp = vp, 
       cl = "spring")
}

We see that once again our constructor is a very thin wrapper around the gTree() constructor, simply ensuring that arguments are converted to units if they are not already.

We now need to create the makeContent() method that creates the actual spring coordinates.

makeContent.spring <- function(x) {
  x0 <- convertX(x$x0, "mm", valueOnly = TRUE)
  x1 <- convertX(x$x1, "mm", valueOnly = TRUE)
  y0 <- convertY(x$y0, "mm", valueOnly = TRUE)
  y1 <- convertY(x$y1, "mm", valueOnly = TRUE)
  diameter <- convertWidth(x$diameter, "mm", valueOnly = TRUE)
  tension <- x$tension
  n <- x$n
  springs <- lapply(seq_along(x0), function(i) {
    cbind(
      create_spring(x0[i], y0[i], x1[i], y1[i], diameter[i], tension[i], n),
      id = i
    )
  })
  springs <- do.call(rbind, springs)
  spring_paths <- polylineGrob(springs$x, springs$y, springs$id, 
                               default.units = "mm", gp = x$gp)
  setChildren(x, gList(spring_paths))
}

There is not anything fancy going on here. We grabs the coordinates and diameter settings from the gTree and converts them all to millimeters. As we now have everything in absolute units we calculate the spring paths using our trusted create_spring() function and puts the returned coordinates in a polyline grob.

Before we use this in a geom let us test it out:

springs <- springGrob(
  x0 = c(0, 0),
  y0 = c(0, 0.5),
  x1 = c(1, 1),
  y1 = c(1, 0.5),
  diameter = unit(c(1, 3), "cm"),
  tension = c(0.2, 0.7)
)
grid.newpage()
grid.draw(springs)

It appears to work and we can now design our new (and final) geom.

24.3 The last GeomSpring

GeomSpring <- ggproto("GeomSpring", Geom,
  setup_params = function(data, params) {
    if (is.null(params$n)) {
      params$n <- 50
    } else if (params$n <= 0) {
      rlang::abort("Springs must be defined with `n` greater than 0")
    }
    params
  },
  draw_panel = function(data, panel_params, coord, n = 50, lineend = "butt", 
                        na.rm = FALSE) {
    data <- remove_missing(data, na.rm = na.rm,
      c("x", "y", "xend", "yend", "linetype", "size"),
      name = "geom_spring")
    if (is.null(data) || nrow(data) == 0) return(zeroGrob())
    if (!coord$is_linear()) {
      rlang::warn("spring geom only works correctly on linear coordinate systems")
    }
    coord <- coord$transform(data, panel_params)
    return(springGrob(coord$x, coord$y, coord$xend, coord$yend,
      default.units = "native", diameter = unit(coord$diameter, "cm"),
      tension = coord$tension, n = n,
      gp = gpar(
        col = alpha(coord$colour, coord$alpha),
        lwd = coord$size * .pt,
        lty = coord$linetype,
        lineend = lineend
      )
    ))
  },
  required_aes = c("x", "y", "xend", "yend"),
  default_aes = aes(
    colour = "black", 
    size = 0.5, 
    linetype = 1L, 
    alpha = NA, 
    diameter = 0.35, 
    tension = 0.75
  )
)
geom_spring <- function(mapping = NULL, data = NULL, stat = "identity", 
                        position = "identity", ..., n = 50, lineend = "butt", 
                        na.rm = FALSE, show.legend = NA, inherit.aes = TRUE) {
  layer(
    data = data, 
    mapping = mapping, 
    stat = stat, 
    geom = GeomSpring, 
    position = position, 
    show.legend = show.legend, 
    inherit.aes = inherit.aes, 
    params = list(
      n = n, 
      lineend = lineend, 
      na.rm = na.rm, 
      ...
    )
  )
}

The main differences from our last GeomSpring implementation is that we no longer care about a group column because each spring is defined in one line, and then of course the draw_panel() method. Since we are no longer passing on the call to another geoms draw_panel() method we have additional obligations in that call. If the coordinate system is no linear (e.g. coord_polar()) we emit a warning because our spring will not be adapted to that coordinate system. We then use the coordinate system to rescale our positional aesthetics with the transform() method. This will remap all positional aesthetics to lie between 0 and 1, with 0 being the lowest value visible in our viewport (scale expansions included) and 1 being the highest. With this remapping the coordinates are ready to be passed into a grob as "npc" units. By definition we understands the provided diameter as been given in centimeters. With all the values properly converted we call the springGrob() constructor and return the resulting grob. One thing we haven’t touched upon is the gpar() call inside the springGrob() construction. grid operates with a short list of very well-defined visual characteristics for grobs that are given by the gp argument in the constructor. This takes a gpar object that holds information such as colour of the stroke and fill, linetype, font, size, etc. Not all grobs care about all entries in gpar() and since we are constructing a line we only care about the gpar entries that the pathGrob understands, namely: col (stroke colour), lwd (line width), lty (line type), lineend (the terminator shape of the line).

ggplot(some_data) + 
  geom_spring(aes(
    x = x, 
    y = y, 
    xend = xend, 
    yend = yend, 
    diameter = diameter, 
    tension = tension
  ))

As can be seen in the example above we now have springs that do not shear with the aspect ratio of the plot and thus looks conform at every angle and aspect ratio. Further, resizing the plot will result in recalculations of the correct path so that it will continues to look as it should.

24.4 Post-Mortem

We have finally arrived at the spring geom we set out to make. The diameter of the spring behaves in the same way as a line width in that it remains fixed when resizing and/or changing the aspect ratio of the plot. There are still improvements we could (and perhaps, should) do to our geom. Most notably our create_spring() function remains un-vectorised and needs to be called for each spring separately. Correctly vectorizing this function will allow for considerable speed-up when rendering many springs (if that was ever a need). We will leave this as an exercise for the reader.

While the geom is now done, we still a have a little work to do. We need to create a diameter scale and provide legend keys that can correctly communicate diameter and tension. This will be the topic of the last extension chapter.