How to recreate DuBois's iconic spiral plot from the 1900 Paris Exposition using R and ggplot2 (CC344)

February 24, 2025 • PD Schloss • 10 min read

Pat uses R to recreate an iconic spiral plot figure that WEB DuBois presented at the 1900 Paris Exposition showing the accumulation of wealth by Black Georgians using tools from the ggplot2, dplyr, and showtext packages. The functions he uses from these packages include aes, annotate, as.character, bind_rows, coord_equal, cos, cumsum, diff, filter, font_add, format, function, geom_polygon, geom_text, ggplot, ggsave, if_else, labs, library, map, max, mutate, nest, paste0, pull, rev, row_number, scale_fill_manual, select, seq, showtext_auto, showtext_opts, sin, sqrt, summarize, theme, tibble, tribble, and unnest. The book Pat mentions by Whitney Battle-Baptiste and Britt Rusert, titled “W.E.B. Du Bois’s Data Portraits: Visualizing Black America” is available at Amazon. A great set of talks about the DuBois data portraits is available here. The Anthony Starks GitHub repository can be found here. If you have a figure that you would like to see me discuss in a future newsletter and episode of Code Club, email me at pat@riffomonas.org!

Code

library(tidyverse)
library(showtext)

font_add("b52", "B52-ULC W00 ULC.ttf")
font_add("vasarely", "vasarely-light.otf")
showtext_auto()
showtext_opts(dpi = 300)

generate_spiral <- function(i_radius, f_radius,
                            i_theta = 9 * pi / 2, f_theta = 3 * pi / 4,
                            resolution = 1000) {
  
  radii <- seq(i_radius, f_radius, length.out = resolution)
  thetas <- seq(i_theta, f_theta, length.out = resolution)
  
  tibble(
    x = radii * cos(thetas),
    y = radii * sin(thetas),
    length = cumsum(c(0, sqrt(diff(x)^2 + diff(y)^2)))
  ) 
}

i_radius <- 15
f_radius <- 9
thickness <- 0.6

value_data <- tribble(
  ~value,~year,
  21186,1875,
  498532,1880,
  736160,1885,
  1173624,1890,
  1322694,1895,
  1434975,1899) %>%
  mutate(fraction = value / max(value),
         position = thickness * (row_number() - 1),
         pretty_value = format(value, big.mark = ",", trim = TRUE),
         pretty_value = if_else(year == 1875,
                                paste0("$ ", pretty_value),
                                pretty_value),
         pretty_value = if_else(year == 1880,
                                paste0("$  ", pretty_value),
                                pretty_value))


spiral_data <- value_data %>%
  mutate(outer_spiral = map(position,
                            ~generate_spiral(i_radius - .x, f_radius - .x)),
         inner_spiral = map(position,
                            ~generate_spiral(i_radius - .x - thickness,
                                             f_radius - .x - thickness))
         ) %>%
  unnest(c(outer_spiral, inner_spiral), names_sep = "_")

reference_length <- spiral_data %>%
  filter(year == 1899) %>%
  summarize(max = max(outer_spiral_length)) %>%
  pull(max)

spiral_data %>%
  mutate(max_length = reference_length * fraction) %>%
  filter(outer_spiral_length <= max_length) %>%
  mutate(inner_spiral_x = rev(inner_spiral_x),
         inner_spiral_y = rev(inner_spiral_y), .by = year) %>%
  nest(spiral = -c(year, value, fraction, position)) %>%
  mutate(x_y = map(spiral,
                   ~bind_rows(select(.x, x = outer_spiral_x, y = outer_spiral_y),
                              select(.x, x = inner_spiral_x, y = inner_spiral_y)
                             ))) %>%
  unnest(x_y) %>%
  ggplot(aes(x = x, y = y, group = year, fill = as.character(year))) +
  geom_polygon(color = "black", linewidth = 0.2,
               show.legend = FALSE) +
  geom_text(data = value_data,
            aes(x = -0.5, y = i_radius - position - thickness / 2,
                label = pretty_value),
            family = "vasarely", size = 7, size.unit = "pt", hjust = 1,
            inherit.aes = FALSE) +
  geom_text(data = value_data,
            aes(x = -6, y =  i_radius - position - thickness / 2,
                label = year),
            family = "vasarely", size = 7, size.unit = "pt", hjust = 0.5,
            inherit.aes = FALSE) +
  annotate(geom = "segment",
           x = -5.1,
           xend = c(-3, rep(-3.9, 5)),
           y = seq(i_radius,
                   i_radius - thickness * 5,
                   -thickness) - thickness/2,
           linewidth = 0.1) +
  annotate(geom = "text",
           label = c("", "", rep('"', 4)),
           x = -3.65,
           y = seq(i_radius,
                   i_radius - thickness * 5,
                   -thickness) - thickness/2,
           family = "vasarely", size = 7, size.unit = "pt"
  ) +
  coord_equal(expand = FALSE, clip = "off") +
  scale_fill_manual(breaks = as.character(c(seq(1875, 1895, 5), 1899)),
                    values = c("#ffc0cb", "#4682b4", "#d2b48c", "#ffd700",
                               "gray80", "#dc143c")) +
  labs(
    title = "ASSESSED VALUE OF HOUSEHOLD AND KITCHEN FURNITURE\nOWNED BY GEORGIA NEGROES.",
    x = NULL,
    y = NULL
  ) +
  theme(
    axis.text = element_blank(),
    axis.ticks = element_blank(),
    panel.grid = element_blank(),
    panel.background = element_blank(),
    plot.title = element_text(family = "b52", size = 12, hjust = 0.5,
                              margin = margin(t = 20, b = 40)),
    plot.margin = margin(b = 80, l = 30, r = 30)
  )

ggsave("plate_25.png", width = 5, height = 6.41, unit = "in")