Spider Plot

Table of Contents

⚠️ Make Sure You Understand the Code Before Using It ⚠️

Introduction

A function to generate a spider plot showing longitudinal changes over time for each individual, commonly used to visualize tumor response or biomarker changes in oncology trials.

Input

  1. data: Longitudinal data with one row per individual per timepoint
  2. x_var: X-axis variable column name (string, e.g., “Time”)
  3. y_var: Y-axis variable column name (string, e.g., “pct_change”)
  4. id_var: ID column name (string)
  5. group_var: Optional grouping variable for colors (string)
  6. reference_lines: Optional numeric vector for horizontal reference lines
  7. title: Plot title (string)
  8. xlab: X-axis label (string)
  9. ylab: Y-axis label (string)
  10. y_breaks: Y-axis breaks (e.g., seq(0, 20, by = 2))
  11. x_breaks: X-axis breaks (e.g., seq(0, 20, by = 2))
  12. color_palette: Optional named vector of colors for groups
  13. legend_title: Legend title (string)
  14. point_size: Size of points (numeric, default 2)
  15. line_width: Width of lines (numeric, default 0.6)
  16. jitter: Whether to jitter points (logical, default FALSE)
  17. jitter_width: Width of jitter for points (numeric, default 0.1)
  18. legend_position: Legend position (numeric vector c(x, y) or string like “bottom”, “right”, “none”)
  19. legend_title_size: Legend title size (numeric)
  20. legend_text_size: Legend text size (numeric)
  21. axis_text_size: Axis ticks text size (numeric)
  22. axis_title_size: Axis label text size (numeric)
  23. x_angle: X-axis text rotation angle (numeric, e.g., -30)

Output

  1. A ggplot2 object

Example Data

library(dplyr)
library(ggplot2)

# Example raw longitudinal data
raw_data <- tribble(
  ~ID, ~date_lab, ~lab_value, ~cycle, ~treatment_arm, ~response_category, ~end_date,
  
  "PT001", "2023-01-01", 50, "Screening", "Arm A", "CR", "2023-12-31",
  "PT001", "2023-01-15", 48, "Cycle 1", "Arm A", "CR", "2023-12-31",
  "PT001", "2023-02-15", 40, "Cycle 2", "Arm A", "CR", "2023-12-31",
  "PT001", "2023-03-15", 30, "Cycle 3", "Arm A", "CR", "2023-12-31",
  "PT001", "2023-04-15", 25, "Cycle 4", "Arm A", "CR", "2023-12-31",
  "PT001", "2023-05-15", 20, "Cycle 5", "Arm A", "CR", "2023-12-31",
  "PT001", "2023-06-15", 20, "Off Treatment", "Arm A", "CR", "2023-12-31",
  
  "PT002", "2023-01-05", 80, "Screening", "Arm B", "PR", "2023-12-31",
  "PT002", "2023-01-20", 75, "Cycle 1", "Arm B","PR", "2023-12-31",
  "PT002", "2023-02-20", 60, "Cycle 2", "Arm B","PR", "2023-12-31",
  "PT002", "2023-03-20", 50, "Cycle 3", "Arm B","PR", "2023-12-31",
  "PT002", "2023-04-20", 45, "Cycle 4", "Arm B","PR", "2023-12-31",
  
  "PT003", "2023-02-01", 100, "Screening", "Arm A", "SD", "2023-12-31",
  "PT003", "2023-02-15", 95, "Cycle 1", "Arm A", "SD", "2023-12-31",
  "PT003", "2023-03-15", 100, "Cycle 2", "Arm A", "SD", "2023-12-31",
  "PT003", "2023-04-15", 90, "Cycle 3", "Arm A", "SD", "2023-12-31",
  "PT003", "2023-05-15", 105, "Cycle 4", "Arm A", "SD", "2023-12-31",
  
  "PT004", "2023-01-10", 60, "Screening", "Arm B", "PD", "2023-12-31",
  "PT004", "2023-01-25", 65, "Cycle 1", "Arm B", "PD", "2023-12-31",
  "PT004", "2023-02-25", 80, "Cycle 2", "Arm B", "PD", "2023-12-31",
  "PT004", "2023-03-25", 100, "Cycle 3", "Arm B", "PD", "2023-12-31",
  "PT004", "2023-04-25", 130, "Cycle 4", "Arm B", "PD", "2023-12-31",
  
  "PT005", "2023-03-01", 70, "Screening", "Arm A", "SD", "2023-05-01",
  "PT005", "2023-03-15", 68, "Cycle 1", "Arm A", "SD", "2023-05-01",
  "PT005", "2023-04-15", 72, "Cycle 2", "Arm A", "SD", "2023-05-01",
  "PT005", "2023-05-15", 85, "Cycle 3", "Arm A", "SD", "2023-05-01",
  "PT005", "2023-06-15", 90, "Cycle 4", "Arm A", "SD", "2023-05-01"
)

Example Data Wrangling

spider_data = 
  raw_data %>% 
  mutate(
    date_lab = as.Date(date_lab),
    end_date = as.Date(end_date), # If any data cut-off date
    cycle_reorder = factor(cycle, levels = c("Screening", paste("Cycle", 1:5), "Off Treatment"))) %>% # edit based on your data
  relocate(cycle_reorder, .after = cycle) %>%
  group_by(ID) %>%
  arrange(ID, date_lab, cycle_reorder) %>%
  mutate(
    ## calculate time elapsed (months) from the start of treatment (cycle 1) to the date of each lab measure, start date could also be screening, depends on the design
    date_elapsed_days = as.numeric(date_lab - date_lab[cycle == "Cycle 1"]),
    date_elapsed_months = as.numeric(date_elapsed_days / 30.437),
    

    ## calculate % change in lab measure from the start of treatment (cycle 1)
    from_cycle1_pct = ifelse(
      cycle == "Screening", 
      NA_real_,
      (lab_value - lab_value[cycle == "Cycle 1"])/lab_value[cycle == "Cycle 1"] * 100
    ),    
  ) %>%
  relocate(from_cycle1_pct, .before = lab_value) %>%
  select(
    ID, 
    date_lab, date_elapsed_days, date_elapsed_months,
    cycle, cycle_reorder, lab_value, from_cycle1_pct, response_category, end_date
  ) %>%
  
  ## filtering data for spider plot
  filter(cycle != "Screening")

# ## data checking
# test = spider_data %>% filter(Cycle == "Cycle 1")
# sum(test$date_lab == test$start_date)
spider_end_date = spider_data %>% filter(date_lab <= end_date)

Function

spider_plot <- function(
    data,
    x_var,
    y_var,
    id_var = "ID",
    group_var = NULL,
    reference_lines = NULL,
    title = NULL,
    xlab = "X-axis",
    ylab = "Y-axis",
    y_breaks = NULL,
    x_breaks = NULL,
    color_palette = NULL,
    legend_title = NULL,
    point_size = 2,
    line_width = 0.6,
    jitter = FALSE,
    jitter_width = 0.1,
    legend_position = NULL,
    legend_title_size = NULL,
    legend_text_size = NULL,
    axis_text_size = NULL,
    axis_title_size = NULL,
    x_angle = NULL
){
  
  # Create internal variables
  data$x <- data[[x_var]]
  data$y <- data[[y_var]]
  data$id <- data[[id_var]]
  
  # Add group variable if provided
  if (!is.null(group_var)) {
    data$group <- data[[group_var]]
  }
  
  # Set legend title (use group_var if legend_title not provided)
  if (is.null(legend_title) & !is.null(group_var)) {
    legend_title <- group_var
  }
  
  # Base plot
  if (!is.null(group_var)) {
    p <- ggplot(data, aes(x = x, y = y, group = id, color = group))
  } else {
    p <- ggplot(data, aes(x = x, y = y, group = id))
  }
  
  # Add lines
  p <- p + geom_line(linewidth = line_width)
  
  # Add points with or without jitter
  if (jitter) {
    p <- p + geom_point(size = point_size, position = position_jitter(width = jitter_width))
  } else {
    p <- p + geom_point(size = point_size)
  }
  
  # Add reference lines
  if (!is.null(reference_lines)) {
    for (ref in reference_lines) {
      p <- p + geom_hline(yintercept = ref, linetype = "dashed")
    }
  }
  
  
  # Add labels
  p <- p +
    labs(
      title = title,
      x = xlab,
      y = ylab,
      color = legend_title
    )
  
  # Add y-axis breaks if provided
  if (!is.null(y_breaks)) {
    p <- p + scale_y_continuous(breaks = y_breaks)
  }
  
  # Add x-axis breaks if provided
  if (!is.null(x_breaks)) {
    p <- p + scale_x_continuous(breaks = x_breaks)
  }
  
  # Add custom color palette if provided
  if (!is.null(group_var) & !is.null(color_palette)) {
    p <- p + scale_color_manual(values = color_palette)
  }
  
  # Apply theme
  p <- p + theme_classic()
  
  # Build theme modifications
  theme_mods <- list()
  
  if (!is.null(legend_position)) {
    theme_mods$legend.position <- legend_position
  }
  
  if (!is.null(legend_title_size)) {
    theme_mods$legend.title <- element_text(size = legend_title_size)
  }
  
  if (!is.null(legend_text_size)) {
    theme_mods$legend.text <- element_text(size = legend_text_size)
  }
  
  if (!is.null(axis_text_size)) {
    theme_mods$axis.text <- element_text(size = axis_text_size)
  }
  
  if (!is.null(axis_title_size)) {
    theme_mods$axis.title <- element_text(size = axis_title_size)
  }
  
  if (!is.null(x_angle)) {
    theme_mods$axis.text.x <- element_text(angle = x_angle, hjust = 1, vjust = -0.5)
  }
  
  # Apply theme modifications if any
  if (length(theme_mods) > 0) {
    p <- p + do.call(theme, theme_mods)
  }
  
  return(p)
}

Plot Without jitter

plot_no_jitter <- spider_plot(
  data = spider_data,
  x_var = "date_elapsed_months",
  y_var = "from_cycle1_pct",
  id_var = "ID",
  group_var = "response_category",
  reference_lines = c(-30, 20),
  title = "Spider Plot - No Jitter",
  xlab = "Time (Months)",
  ylab = "% Change from Baseline",
  y_breaks = seq(0, 140, 20),
  x_breaks = seq(0, 5, 1),
  color_palette = c("CR" = "green", "PR" = "#0089D0", "SD" = "#442288", "PD" = "#CC004C"),
  legend_title = "Response Category",
  jitter = FALSE
)

Plot With jitter

plot_with_jitter <- spider_plot(
  data = spider_data,
  x_var = "date_elapsed_months",
  y_var = "from_cycle1_pct",
  id_var = "ID",
  group_var = "response_category",
  reference_lines = c(-30, 20),
  title = "Spider Plot - No Jitter",
  xlab = "Time (Months)",
  ylab = "% Change from Baseline",
  y_breaks = seq(0, 140, 20),
  x_breaks = seq(0, 5, 1),
  color_palette = c("CR" = "green", "PR" = "#0089D0", "SD" = "#442288", "PD" = "#CC004C"),
  legend_title = "Response Category",
  jitter = TRUE,
  jitter_width = 0.2
)