8  Advanced Visualization

9 Advanced GGplot usage

Data used in this chapter

For the examples in this chapter, we will use a dataset of state-level presidential voters from 1976 to 2024 published by the MIT Election Data + Science Lab:

As you can see, this dataset contains the total number of votes for each candidate in each U.S. State for the elections from 1976.

For convenience, let’s also create a dataset of aggregated results for each election

Note the use of summarize followed by a sum(votes) in the subsequent mutate: since summarize by default drops the last grouping level, the second sum(votes) is summed at the year level, so each party’s votes is divided by the total number of votes that year.

Finally, we will use a data set of state-level metadata from a 2018 analysis by the same MIT lab:

This contains the total population, percentage White, percentage young (<30) and old (65), median household income and percentage college educated per state (except Alaska, strangely)

9.2 Theming your plot

The customizations in the previous section all changed the way data was visualized in the plot. With theming, we can control the other visual aspects of the plot such as the backgroun color, font styles, grid lines etc.

As an example, let’s spice up the first plot used above:

This accomplishes a number of things:

First, theme_classic() selects an existing theme as a starting point. If you type theme_ and then press control_space, Rstudio should show you a list of available themes (Note that this also works great with geom_ and scale_!)

Subsequently, the theme() function further customizes the theme:

  • All text is set to use the “monospace” font at size 12 using the element_text helper function. Note that you can also set the font for individual elements, e.g. plot.title or axis.title.
  • The plot background is set to a delightful pink using the element_rect (rectangle) helper
  • The major horizontal gridlines are set to a dashed grey line using element_line. Note that you can also set the style for more grid lines using a less specific argument, e.g. panel.grid= or panel.grid.major=.
  • The x axis title is removed by setting it to element_blank() (which can be used to remove almost all plot elements, e.g. legend.title=element_blank() removes the legend title)
  • Finally, the legend is moved to the bottom with the title displayed on the top. You can also use legend.position="none" to disable all legends for the plot.

This only shows a tiny fraction of the available theming options, but they all work similarly to these so you can just play around with it. The argument names are very systematic, so you can e.g. type theme(panel. and press control+space to get a list of available panel options. For a complete list and explanation of available arguments, see the documention for the theme function

Please note that font rendering is highly device specific. The above example used the monospace font family, which (probably) works in your browser because the browser recognizes that family, but probably won’t work in Rstudio which ships with family=mono. For a more flexible handling of fonts, please see the showtext package.

As a final note, you can see overlap between labs, scale, guide, and theme for some options: legend position can be set either in the guide or in the theme; legend titles can be set either in the labs or in the scale; and axis and other titles can be removed in the labs, the scale, or the theme. Any of those places is fine, so just put it where it’s most convenient.

Tiplibrary(scales) vs. scales::

In the last example, we used scales::label_percent rather than calling library(scales) at the top.

Technically, using library attaches the package, making all functions available directly in the rest of the session. Sometimes, however, it’s more convenient to use the package::function notation instead: it makes it clearer where a function comes from, and prevents having 20 library() calls at the top of a script of which some might not even be used anymore.

Either notation works fine, and you can choose which you prefer. As a rule of thumb, I would advise using library(package) for packages (such as tidyverse) that are used throughout a script, and package:: for functions that are only used once or twice.

Re-using themes

If you are writing a report with multiple figures, it makes sense to set things like font and color preferences globally rather than on each plot separately. There are two mechanisms for this.

First, you can use theme_set() to change defaults for all plots in this session:

Note that you can call theme_set() without argument to revert back to the default theme.

Alternatively, since themes are just objects, you can assign your theme to a variable and re-use it:

9.3 Comparing groups side by side: facets

When visualizing complex data, it can be tempting to put as much information in a graph as possible. In theory, every aesthetic can be used to represent a variable, so it’s possible to visualize many different variables. However, putting too much information in a plot can quickly render it very hard to read: 3 lines in a graph is fine, but 12 different line colors with 4 different line types is often very hard to read.

An alternative way to display more information is to use facets. For example, we can plot vote shares in a number of key states:

As you can see, this produces a separate graph for each of the selected states. This works by using facet_wrap, which automatically arranges the graphs in a grid which can be controlled by setting nrow and ncol. As you can see, by default the x and y axis scales are kept the same for all graphs, so you can easily compare values between graphs.

Note that for technical reasons, you need to enclose the column names in vars() similar to how you put the columns in aes for specifying the mapping.

Facet on multiple variables with facet_grid

The example above showed faceting on a single variable. If you have multiple variables that you want to facet on, you can also use facet_grid to specify which variables to use as rows and columns.

To show this, let’s go back to the CO2 emissions data introduced in Section 7.2.3:

Now, let’s create separate graphs per country and greenhouse gas type for four large emitters:

As you can see, this places the four countries in the columns, and places separate graphs in the rows for each of the greenhouse gas types. By specifying scales="free_y", it allows the vertical scales to differ between the rows, avoiding methane and nitrous oxides being dwarved by the much higher co2 emissions.

9.4 Annotations

A central goal in making good graphs is to ensure that a graph should be self-explanatory: it should be understandable and interpretable without needing to read or understand the surrounding text. Most of the tools needed for this have been discussed already, especially good plot and axis titles and useful legends.

Sometimes, however, it really helps to add annotations to a graph to make it easier to understand. For example, let’s add some annotations to the line graph made in the first example:

This shows three different annotations:

  • First, the geom_hline paints a simple horizontal line at y-intercept 0.5 (50%), using color, linewidth and lty (line type) to style the line. Note that this geom is placed before the actual plot geom to ensure that the plot lines are drawn over the hline: ggplot plots the geoms in the order given, so later geoms overwrite earlier ones.
  • Next, we place a textual annotation at the position of the ‘other’ vote share in 1992. Using vjust=0 justifies the label so the bottom is at the given y value. This uses the “label” annotation, which draws a small box around the text.
  • Finally, we add a regular “text” annotation to explain the 50% line. This is positioned all the way at the right hand side, with x=2024 and hjust=0, which left aligns the text to start from 2024. Of course, the graph only runs to 2024, so it would normally be mostly outside the plotting area. This is solved by increasing the right margin in the theme, and setting clip="off" in the cartesian coordinates, meaning that the text won’t be clipped if it’s plotted outside the actual plotting limits.

Finally, note the use of breaks = scales::breaks_width(4) and guide=guide_axis(angle=90)) in the scale_x. This ensures every election receives a label on the x axis, setting them at 90 degrees to avoid overlapping.

9.5 Exercise: Recreating a published figure

Let’s bring together stacking, colors and annotations to recreate a figure from a real published article.

Let’s put stacking, colors and annotations together to recreate a real published figure: Figure 1 from the original 538 article, The Dollar-And-Cents Case Against Hollywood’s Exclusion of Women (open it in a new tab so you can compare as you go).

The starting code below already does the data preparation: it bins year into 5-year periods labeled like 1970-'74, and strips the -disagree suffix from test so we’re left with five categories: nowomen, notalk, men, dubious and ok, from clearest fail to clearest pass. We’ve also created two helper variables: the 538 color palette with red shades for the “fail” categories, and blue shades for “pass”; and a breaks variable that contains the labels that should be shown on the y axis:

Note that this code uses some interesting ‘tricks’:

  • We compute the lustrum (5 year period) by dividing, flooring, and multiplying again.
  • We create a period variable using str_glue, ‘glueing’ together the start year, a literal dash, newline and apostrophe, and the last two digits of the end year of that period (see (sec_str?) for more details on the str_* functions)
  • We extract the unique periods with pull(column) |> unique() |> sort(), and then select all odd labels by indexing with a sequence from 1 to the number of periods, incrementing by 2 every step.

A number of things are still missing:

  • The bar plot should be turned into a stacked proportion scaling every bar to 100%, with the correct ordering of the factor levels.
  • The colors given above should be used for the bars
  • You should add a title and subtitle to match the original plot
  • The Y axis should be in percentages with the right labels
  • The X axis should have the labels only for the first half of each decade
  • You should add two text annotations for PASS and FAIL in the top and bottom center, respectively.

Fix all the TODOs below. You can run the graph at any moment to see how you are doing, and of course you can ask for hints on which functions to use.

TipHint:
  • TODO 1: reorder inside the fill mapping itself with factor(test, levels = ...) – and since colors is already a named vector in the right order, names(colors) gives you that order without retyping it. Remember that position_stack() (used by both "stack" and "fill") places the first factor level at the top of the bar, so ok (the clearest pass) needs to be the last level to end up at the bottom. For turning the counts into a 0-100% stack, look at the position argument of geom_bar().
  • TODO 2: scale_fill_manual(values = ...) takes a named vector mapping category to color – colors already is one.
  • TODO 3: labs(title = ..., subtitle = ...).
  • TODO 4: scale_y_continuous(labels = ...)scales::label_percent() is a ready-made label function for this.
  • TODO 5: scale_x_discrete(breaks = ...) – the breaks variable already contains exactly the labels you want shown.
  • TODO 6: two annotate("text", label = ..., x = ..., y = ...) calls, one placed in the upper half of the bars (around y=.75) and one in the lower half (around y=.25).
Important🎓 Solution:

Since position_stack() puts the first factor level at the top, listing test from clearest fail (nowomen) to clearest pass (ok) puts ok at the bottom of each bar, matching the original. position="fill" turns the counts into proportions. Given that stacking order, the upper half of the bars (y=.75) is the “fail” categories, and the lower half (y=.25) is “pass”.

ggplot(fig1data) + geom_bar(aes(x = period, fill = factor(test, levels = names(colors))), position = "fill") + scale_fill_manual(values = colors, guide = "none") + labs(title = "The Bechdel Test Over Time", subtitle = "How women are represented in movies") + scale_y_continuous(name = "", labels = scales::label_percent()) + scale_x_discrete(name = "", breaks = breaks) + annotate("text", label = "FAIL", y = .75, x = 5, size = 20, fontface = "bold") + annotate("text", label = "PASS", y = .25, x = 5, size = 20, fontface = "bold") + theme_minimal() + theme(panel.grid = element_blank())
ggplot(fig1data) +
  geom_bar(aes(x = period, fill = factor(test, levels = names(colors))), position = "fill") +
  scale_fill_manual(values = colors, guide = "none") +
  labs(title = "The Bechdel Test Over Time",
       subtitle = "How women are represented in movies") +
  scale_y_continuous(name = "", labels = scales::label_percent()) +
  scale_x_discrete(name = "", breaks = breaks) +
  annotate("text", label = "FAIL", y = .75, x = 5, size = 20, fontface = "bold") +
  annotate("text", label = "PASS", y = .25, x = 5, size = 20, fontface = "bold") +
  theme_minimal() +
  theme(panel.grid = element_blank())

Compare your result to the original figure. A few things are still different:

  • The original draws a thick black line separating “pass” from “fail” in each bar.
  • Functioning as a legend, the original has small labels on the right-hand side naming each of the five categories, connected to the chart with a short tick mark.
  • Colors, fonts, axis styling – how many of the remaining differences can you track down and fix?

Some of these (especially the dividing line and the side labels) go well beyond what we’ve covered in this book. The {webr} block below starts you off with the solution to the exercise above – feel free to experiment, look up ggplot functions you haven’t seen yet, and see how far you get. There’s no single right answer here, and no checker: this is purely for the fun of it.

Below is our solution – don’t peek until you’ve had a go yourself!

Here’s how far we took it. This uses several tricks not covered elsewhere in the book which are explained below the code, but have a look at the code first and see if you can understand what’s going on. There are many other ways to reach a similar result – this is just one of them.

Some explanation:

  1. We first define the colors, labels and positioning for the outcome labels in a separate data frame The alternative is spreading this information over the graph in various scale, annotation etc. calls, which makes it much harder to change later. In general, it can be a good idea to place this kind of information in a separate data frame rather than inside long blocks of code since it separates substantive decisions from implementation code.
  2. Next, we compute the figure data as before, but join it with the outcomes data frame created in (1). This makes the color and positioning of the outcomes available in the plot.
  3. We then compute the positioning of the outcome labels on the right hand side. The goal is to compute a y position that is halfway in the bar for that outcome in the last period. This first counts the number of films in each outcome for the last period using count, and converts it to a percentage by dividing by sum(n). Next, it takes the cumulative sum of these percentages ordered by descending position, which effectively computes the upper limit of each bar since they are stacked. Finally, we subtract half of the current bar’s size to get the midpoint for the bar.
  4. We compute the y position of the line separating pass from fail for each bar. The trick here is computing the mean of a boolean (yes/no) variable, i.e. whether it has passed. R treats TRUE as 1 and FALSE as zero, so the mean is the same as the percentage of cases that passed. A second trick is defining the x position as a number based on the factor level. For categorical axes, ggplot also accepts numerical positions from 1 to the number of bars drawn.
  5. In the ggplot call, we set the width of the bars to 0.96 to mimic the small gaps in the original. We can now also set the fill to outcome_color and use scale_fill_identity, which tells ggplot to treat the values as actual color names rather than categorical values that need to be mapped to a palette. We can also use fct_reorder(outcome_color, position) here since we joined the position column to the data, so we can order the outcomes based on this position.
  6. We now add the line between the pass and fail bars based on the pass_line data constructed in (4). Note that ggplot will draw the first bar with 1 as the center, so a bar of width 0.96 will be drawn from 0.52-1.48, leaving a 4 point gap between the bars. So, for the horizontal bars, we compute x as x - .52 and xend as x + .52: since the width of each bar is .96, ggplot renders it from -0.48 to 0.48 with a .04 gap between two bars, so we use .52 to cover the gap as well. This would also extend left and right of the first and last bars, so we use pmax and pmin to limit the values to 0.52 and 9.48 rather than 0.48 and 9.52 for the first and last value. For the vertical bars, we draw a line between this bar and the next, setting x=x+.5 to point to the right hand side of the bar, and draw a line from y to lead(y), which takes the next (‘leading’) value of the same column. For the last bar this will give NA (since there is no next bar), so no vertical bar is drawn at the end.
  7. The pass/fail annotations are simple text annotations. For the outcome labels and segments, however, we can now use the labels dataset we created in the step (3), and use a geom_segment and geom_segment line based on this dataset rather than having to specify 5 separate annotations for the text and lines.
    We draw the line from 9.48 to 9.6 and start the text at 9.7 (left-justified), which draws them to the right of the last bar, again using the fact that the last (9th) bar is drawn to 9.48.
  8. The original figure had 100% for the top mark, but 0, 25 etc without percentage sign for the rest. There is no built-in option for this, but we can achieve this by using a function to compute the label, setting it to "100%" for y==1 and to a simple y*100 for the rest
  9. The original figure had no space between the bottom of the bar and the axis line. This is copied by setting the expansion of the vertical scale to 0 for the bottom and 5% for the top using mult=c(0, 0.05). Similarly, the space to the left of the graph is set to exactly 0.48 to ensure there is no space between the bar and the tick marks, using add rather than mult since here we give the exact space to be added to the plot, rather than a factor with which to multiply it. 10.To copy the grey bar with the source attribution, we use annotations in negative vertical positions. Note that these are dependent on the exact size of the graph, so would need to be tweaked after fixing the exact output format. 11.Finally, we make space for the outcome labels on the right and the grey attribution bar by setting larger margins on the right and bottom of the plot and specifying clip="off" in the coordinate function (to prevent these elements from being clipped). We also fix the xlim and ylim to make sure the axes don’t extend beyond the actual bar chart, because otherwise the negative values used in the grey bar would be seen as part of the plotting area, and the axes would actually be drawn below the gray bar instead of the other way around.

As said, this shows a number of “tricks” that might be useful in specific circumstances. Some things, such as using a data frame for the outcome colors and labels, using a geom with separate data for plotting multiple data-driven annotations, and using numbers for horizontal positions in categorical scales are useful in many circumstances. Using the black bar between the blue-ish and red-ish colors and the big pass/fail annotations also drive home the overall message in a very clear way. Using annotations linked to the data rather than a generic legend is also quite a useful way to make a graph both more aesthetically pleasing and easier to read, since the reader doesn’t have to make a connection between the colors and labels.

Other tricks, especially the grey bar at the bottom and suppressing the percentage mark, are not as useful and done here mostly to showcase how much you can customize plots if desired. Especially the use of a custom annotation which has to be fine-tuned every time the plot size changes instead of a simple caption= is probably not really worth it in most applications. But now you know that you can if you really want to!