Using ggdiagram

    library(ggplot2)
    library(ggdiagram)

Making a simple diagram with a point-and-click drawing program saves time and can produce good results if handled with care. However, making a complex diagram worthy of publication can take many hours of fuss and bother.

When I discovered TikZ, I was amazed at how much better my diagrams could be. TikZ knows things about the objects it draws—like where lines and objects intersect and how far they are from each other. Instead of eyeballing where to place objects, I could tell TikZ exactly where to draw objects in relation to each other (e.g., draw a circle to the right of rectangle with 4cm of space between them). I suspect that the precision, consistency, and beauty of TikZ diagrams add to their persuasive power because readers intuit that such figures are the product of careful deliberation.

Because I mainly compute statistical models in R, getting information from R to TikZ was an intensive process, and integrating TikZ graphics with R graphics was often an exercise in frustration. Though I very much respect the labor of those who have made the TikZ ecosystem open and flexible, I never felt at ease when trying to automate TikZ or extending its capabilities.

When the S7 package became available, it seemed that what I wanted was within my grasp: to draw diagrams with an object-oriented approach, welding the precision of TikZ with the flexibility of R. The ggdiagram package makes objects that can hold or compute various properties and can be placed directly in a ggplot2 plot. I do not imagine that ggdiagram will ever duplicate the full functionality of TikZ, but being able to make TikZ-like diagrams within R eliminates many pain points from my previous workflow and offers a world of possibilities.

# First steps

The ggplot2 ecosystem already has the ability to create points, lines, and shapes, and it is ideally suited for the display of data and statistical trends. There is no reason to use ggdiagram for the tasks that ggplot2 already does fabulously well. However, once ggplot2 creates points, lines, and shapes, it is not always easy to extract information about them to create new objects. For example, if I draw a line segment from the center of an ellipse to the center of a rectangle, where does the line intersect with the ellipse and the rectangle? Where is the point midway between those intersection points? These quantities can be computed with a bit of algebra and trigonometry, but when making complex figures, such computations are tedious distractions.

The functions in ggdiagram with the `ob_` prefix (e.g,. `ob_point`, `ob_line`, and `ob_circle`) create objects using S7, which allows the objects to hold information about the object’s properties (e.g., location, color, and rotation angle) and to compute the location of its edges and points of intersection with other objects. Properties of S7 objects are extracted with the `@` operator. For example, we can create a circle with the `ob_circle` function and extract various properties:

    x <- ob_circle()
    x@radius
    #> [1] 1
    x@circumference
    #> [1] 6.283185
    x@area
    #> [1] 3.141593
    x@diameter
    #> [1] 2

# Simple Plots

As seen in Figure 1, objects with the `ob_` prefix can be added to any ggplot object in the usual manner.

    # Plot
    ggplot() +
      ob_circle(radius = 1) +
      ob_rectangle(width = 2,
                   height = 2) +
      coord_fixed() +
      theme_void()

Figure 1: Adding objects using the `ggplot` function

The `ggdiagram` function calls the `ggplot` function, sets the ggplot2 theme (defaults to `theme_void`), and also sets the defaults of key geoms so that font families, font sizes, line widths, and point sizes do not have to be specified repeatedly.

In Figure 2, we can locate points on a circle’s circumference at any angle. The `degree` function makes it easy to compute and label angles as degrees.

    theta <- degree(c(0, 53, 90, 138, 180, 241, 270, 338))
    x <- ob_circle()
    ggdiagram(font_size = 18) +
      x +
      x@center +
      x@point_at(theta)@label(theta)

Figure 2: The center of a circle and a point at an angle.

# Styling an object

Objects in ggdiagram can be styled in any way that its underlying geom can be styled. Let’s create a segment and its two endpoints.

    p1 <- ob_point(-3, 2)
    p2 <- ob_point(1, 5)
    s1 <- ob_segment(p1, p2)
    bp <- ggdiagram(theme_function = ggplot2::theme_minimal,
                    font_size = 18)
    bp +
      p1 +
      p2 +
      s1

The primary options for styling a segment are alpha, color, linetype, and linewidth. However, it can take any style from `ggarrow::geom_arrow_segment`

If you are not sure which properties can be set, you an see them in the `@aesthetics@style` slot.

    s1@aesthetics@style
    #>  [1] "alpha"            "arrow_head"       "arrow_fins"       "arrowhead_length"
    #>  [5] "color"            "length_head"      "length_fins"      "lineend"
    #>  [9] "linejoin"         "linewidth"        "linewidth_fins"   "linewidth_head"
    #> [13] "linetype"         "resect"           "resect_fins"      "resect_head"
    #> [17] "stroke_color"     "stroke_width"

Styles can be specified when the object is created.

    s2 <- ob_segment(p1, p2, color = "green4")
    bp + s2

Figure 3: Styling an object

Styles can be modified after the segment is created:

    s2@linewidth <- 3
    bp + s2

Figure 4: Changing an object’s style after it has been created.

A “pipe-friendly” way to modify any ggdiagram object is to use S7’s `set_props` function, which has been re-exported to ggdiagram for the sake of convenience.

    bp +
      s1 |>
      set_props(color = "red",
                linetype = "dashed")

Figure 5: Setting styles in a ggplot pipeline.

# Making Styles Stick with `redefault`

To style an object the same way repeatedly, use the `redefault` function to create an alternate version of an `ob_*` function with the default arguments set differently. The `redefault` function is like `purrr::partial` except that preset arguments can still be modified. It can be used on any R function.

    ob_squircle <- redefault(
      .f = ob_ellipse,
      m1 = 4,
      color = NA,
      fill = "black")

    ggdiagram() +
      ob_squircle() +
      ob_squircle(x = 3, fill = "dodgerblue")

Figure 6: The `redefault` function creates a copy of a function with new defaults

# Apply Styles Across Shapes with `ob_style`

Suppose you want to create a list of styles that you want to pass to multiple objects to create a cohesive style. The `ob_style` function can contain styles that can be passed along to other functions.

    style_shape <- ob_style(color = NA,
                            fill = "dodgerblue4",
                            family = "serif")

    my_label <- redefault(
      ob_label,
      color = "white",
      fill = NA,
      family = "serif",
      size = 18
    )

    ggdiagram() +
      ob_circle(
        x = -3,
        style = style_shape,
        label = my_label("Circle")) +
      ob_rectangle(
        width = 3,
        style = style_shape,
        label = my_label("Rectangle")
      )  +
      ob_ellipse(
        x = 3,
        b = 1.5,
        style = style_shape,
        label = my_label("Ellipse")
      ) +
      ob_ngon(
        y = 2,
        n = 6,
        angle = degree(90),
        style = style_shape,
        label = my_label("Hexagon")) +
      ob_ngon(
        y = -2,
        n = 3,
        angle = degree(90),
        style = style_shape,
        label = my_label("Triangle", nudge_y = -.25))

Figure 7: The `ob_style` function creates a list of styles that can be passed to other ggdiagram functions

# Placing Objects in Relation to Other Objects

The `place` function will place an object a specified distance and direction from another object. Here we place a 1 × 1 rectangle above unit circle `x` such that the separation between them is .5 units. The `connect` function draws an arrow between them, with 2mm of space resected.

    x <- ob_circle()
    y <- ob_rectangle() |>
      place(from = x,
            where = "above",
            sep = .5)

    ggdiagram() +
      x +
      y +
      connect(x, y, resect = 2)

Figure 8

The `where` parameter can be specified with

- Direction words like left, right, above, top, below, bottom, above left, top right, below right, bottom left, etc.
- Cardinal points like north, south, east, west, northeast, southwest, north-northeast, west-southwest, etc.
- Degrees using the `degree` function (e.g., `degree(21)`)
- Radians using the `radian` function (e.g., `radian(pi)`)
- Turns using the `turn` function (e.g., `turn(1/5)`)

# Object Arrays with `ob_array`

Sometimes it is useful to make arrays of objects. The `ob_array` function makes `k` copies of an object, arranges them in a line in a specified direction, and separates the objects by a specified amount. By default, the array is arranged horizontally, centered on the original object, separated by 1 unit. Here we make an array of 5 circles.

    ggdiagram(font_size = 18) +
      ob_circle() |>
      ob_array(k = 5,
               label = 1:5,
               where = "southeast",
               sep = .5)

Figure 9: An array of 5 unit circles, separated by 0.5, pointing southeast

# Building Plots Sequentially in a ggplot2 Pipeline

Often we want to build a diagram in which newer objects depend on previously specified objects. To assign a variable in the middle of a ggplot2 workflow, we can enclose an assignment statement in curly braces `{}`.

Figure 10 makes use of a curly braces to create:

- Latent variable A as a circle with radius 2,
- Observed variables A1–A3 3 units below A
- Loading paths (connecting arrows) from the latent A to observed variables A1–A3
- Error variance paths for each observed variable.

    # Loadings
    loadings <- c(.86, .79, .90)
    error_variances <- sqrt(1 - loadings ^ 2)

    ggdiagram(font_size = 16) +
      # Latent variable
      {A <- ob_circle(radius = 2,
                      label = ob_label("A",
                                       size = 96,
                                       nudge_y = -.15))} +
      # Observed variables (array of 3 superellipses below A)
      {A_3 <- ob_ellipse(m1 = 20) |>
        place(from = A,
              where = "below",
              sep = 3) |>
        ob_array(
          k = 3,
          sep = .5,
          label = ob_label(
            label = paste0("A~", 1:3, "~"),
            size = 32,
            vjust = .6
          )
        )} +
      # Observed variable loadings with labels set to the same y coordinate
      connect(
        from = A,
        to = A_3,
        resect = 2,
        label = ob_label(label = round_probability(loadings),
                         angle = 0))@set_label_y() +
      # Error variances
      ob_variance(
        A_3,
        where = "south",
        looseness = 1.2,
        bend = -20,
        label = ob_label(label = round_probability(error_variances))
      )

Figure 10: A latent variable with 3 observed indicators

# Changing the default arrowhead

Functions that can make arrows (e.g., `connect`, `ob_segment`, `ob_arc`, `ob_path`, `ob_bezier`) use functions from the ggarrow package. The arrowheads are fully customizable. The arrowheadr package has functions for many custom arrowheads and for creating new arrowheads.

The `arrowhead` function returns a matrix that draws the default arrowhead.

    arrowhead()
    #>
    #> ── <class_arrowhead>
    #> A matrix with 2 columns and 200 rows. First 6 rows:
    #>          x    y
    #> [1,] -0.39 0.00
    #> [2,] -0.39 0.02
    #> [3,] -0.39 0.04
    #> [4,] -0.40 0.06
    #> [5,] -0.40 0.08
    #> [6,] -0.40 0.10

The default arrowhead is an elongated deltoid shape (i.e., `arrowheadr::arrow_head_deltoid(d = 2.3)`). Specifying a different arrowhead repeatedly is tedious.

Within the current R session, the default arrowhead can be changed to anything desired. For example,

    set_default_arrowhead(ggarrow::arrow_head_wings(offset = 30))
    arrowhead()
    #>
    #> ── <class_arrowhead>
    #> A matrix with 2 columns and 4 rows.
    #>         x     y
    #> [1,]  1.0  0.00
    #> [2,] -0.5  0.87
    #> [3,]  0.0  0.00
    #> [4,] -0.5 -0.87

Now any function that uses the default arrow will use the new default.

    ggdiagram() +
      x +
      y +
      connect(x, y, resect = 2)

The original default can be restored like so:

    set_default_arrowhead()
    arrowhead()
    #>
    #> ── <class_arrowhead>
    #> A matrix with 2 columns and 200 rows. First 6 rows:
    #>          x    y
    #> [1,] -0.39 0.00
    #> [2,] -0.39 0.02
    #> [3,] -0.39 0.04
    #> [4,] -0.40 0.06
    #> [5,] -0.40 0.08
    #> [6,] -0.40 0.10