rggplot2geom-segment

Adding arrowheads to errorbars in ggplot2 in R where they go above or below limits


I am using ggplot2 in R and I am trying to add arrowheads to the errorbars on my plot only where the errorbar goes above or below the upper_limit/lower_limit in the plot below (see code for limit definitions). I have played around with geom_segment and can add arrowheads but they are in the incorrect position. I had a similar issue previously with errorbars and points not aligning but the same fix e.g., with group does not seem to work here (see previous question and answer here. Does anyone have any suggestion on how to fix this please?

Code to produce plot with incorrect positioning of arrowheads:

# Define custom_colors as a named vector
custom_colors <- c("HR" = "#97D9E3", "OR" = "#A59BEE", "RD" = "#FDB633", "cont" = "#F6A4B7")

custom_shapes <- c("Obs" = 15, "Between-family" = 24, "Within-family" = 25, "1SMR" = 16, "2SMR" = 18)

pd <- position_dodge(width = 1.0)

line_data <- data.frame(
  xintercept = seq(0.5, 10.5, by = 1)  # Adjust this based on the number of outcomes
)

upper_limit <- 3.5
lower_limit <- -2.3
n_methods <- 5
dodge_width <- 1.0

df_upper_arrows <- df_filtered %>%
  filter(UCI > upper_limit) %>%
  mutate(dodged_x = as.numeric(as.factor(Outcome)) + 
                    (as.numeric(Method) - 1.5) * dodge_width / n_methods)

# Subset for arrows below lower limit
df_lower_arrows <- df_filtered %>%
  filter(LCI < lower_limit) %>%
  mutate(dodged_x = as.numeric(as.factor(Outcome)) + 
                    (as.numeric(Method) - 1.5) * dodge_width / n_methods)

# Create the combined plot
p <- ggplot(df_filtered, aes(x = Outcome)) + 
 
  geom_vline(data = line_data, aes(xintercept = xintercept), 
             color = "white", linetype = 1) +
  
  geom_hline(yintercept = 0, color = "#646363", linetype = "dashed") +
  
  # Points for cont and RD on the second y-axis
  geom_point(data = df_filtered,
             aes(y = Effect_estimate, shape = Method, color = effect_type, fill = effect_type),
             size = 3, stroke=0, position = pd) +

  # Confidence intervals for cont and RD on the second y-axis
  geom_errorbar(data = df_filtered,
                aes(ymin = pmax(LCI, lower_limit), ymax = pmin(UCI, upper_limit), color = effect_type, group = interaction(Method, drop=FALSE)),
                width = 0, position = pd, size = 0.5) +
  
# Arrows for points where UCI exceeds the upper limit
  geom_segment(data = df_upper_arrows,
               aes(x = dodged_x, xend = dodged_x, 
                   y = upper_limit, yend = upper_limit + 0.2, 
                   color = effect_type, group = interaction(Method, drop = FALSE)),
               arrow = arrow(length = unit(0.1, "cm"), type = "open"),
               size = 0.5) +

  # Arrows for points where LCI is below the lower limit
  geom_segment(data = df_lower_arrows,
               aes(x = dodged_x, xend = dodged_x, 
                   y = lower_limit, yend = lower_limit - 0.2, 
                   color = effect_type, group = interaction(Method, drop = FALSE)),
               arrow = arrow(length = unit(0.1, "cm"), type = "open"),
               size = 0.5) +
  
  # Customize the theme
  theme_minimal(base_size = 15) +
  theme(panel.background = element_rect(fill = "gray90", color = NA), # Gray background
        panel.grid.major.x = element_blank(),  # No major vertical gridlines
        panel.grid.minor.x = element_blank(), # Optional: Hide minor vertical gridlines
        panel.grid.major.y = element_line(color = "white"), # Keep y major gridlines
        panel.grid.minor.y = element_line(color = "white", linewidth = 0.5),
        legend.position = "right",
        axis.text.x = element_text(angle = 45, hjust = 1)) +
  labs(y = "Effect Estimate (OR)", x = "Outcome", color = "Effect Type", fill = "Effect Type", shape = "Method") +
  
  scale_fill_manual(values = custom_colors, na.translate = FALSE, labels = c("cont" = "Continuous")) +  # Use custom colors for effect types
  scale_color_manual(values = custom_colors, na.translate = FALSE, labels = c("cont" = "Continuous")) +  # Use custom colors for effect types
  scale_shape_manual(values = custom_shapes, labels = c("Obs" = "Observational")) + 
  
  # Secondary y-axis for cont and RD
  scale_y_continuous(limits = c(-2.5,3.8), labels = c("0.14", "1", "7.39", "54.60"), sec.axis = sec_axis(trans = ~ ., name = "Effect Estimate (Continuous, RD)")) +

  guides(shape = guide_legend(override.aes = list(fill = "black", stroke = 0)))

# Print the plot
print(p)

Plot below: enter image description here

The dataset is here:

Exposure,Outcome,Method,Model,Effect_estimate,LCI,UCI,p_value,units,effect_type,SD
Loneliness,Self harm,Obs,logistic,0.2695129,0.23552845,0.3053514,6.33E-53,yes/no,OR,NA
Loneliness,Self harm,Between-family,logistic,0.1931246,0.01703334,0.3673559,3.00E-02,yes/no,OR,NA
Loneliness,Self harm,Within-family,logistic,0.2278867,0.05307844,0.4048337,1.00E-02,yes/no,OR,NA
Loneliness,Self harm,1SMR,ivreg,0.31,0.08,0.54,7.51E-03,yes/no,RD,NA
Loneliness,Self harm,2SMR,NA,NA,NA,NA,NA,NA,NA,NA
Loneliness,Suicide attempt,Obs,logistic,0.2764618,0.23044892,0.3242825,8.76E-31,yes/no,OR,NA
Loneliness,Suicide attempt,Between-family,logistic,0.2787536,0.04139269,0.519828,2.00E-02,yes/no,OR,NA
Loneliness,Suicide attempt,Within-family,logistic,0.2671717,0.02530587,0.510545,3.00E-02,yes/no,OR,NA
Loneliness,Suicide attempt,1SMR,ivreg,0.15,-0.00743,0.31,6.00E-02,yes/no,RD,NA
Loneliness,Suicide attempt,2SMR,IVW,0.3838154,0.16435286,0.6009729,5.57E-04,NA,OR,NA
Loneliness,Depression diagnosis,Obs,logistic,0.4048337,0.38738983,0.4199557,0.00E+00,yes/no,OR,NA
Loneliness,Depression diagnosis,Between-family,logistic,0.4248816,0.34830486,0.5010593,4.64E-28,yes/no,OR,NA
Loneliness,Depression diagnosis,Within-family,logistic,0.3521825,0.2764618,0.4265113,8.17E-20,yes/no,OR,NA
Loneliness,Depression diagnosis,1SMR,ivreg,0.35,0.15,0.55,5.18E-04,yes/no,RD,NA
Loneliness,Depression diagnosis,2SMR,IVW,0.3283796,0.15228834,0.50515,2.74E-04,NA,OR,NA
Loneliness,Anxiety diagnosis,Obs,logistic,0.2787536,0.26007139,0.2966652,1.73E-180,yes/no,OR,NA
Loneliness,Anxiety diagnosis,Between-family,logistic,0.3031961,0.21748394,0.3909351,5.56E-12,yes/no,OR,NA
Loneliness,Anxiety diagnosis,Within-family,logistic,0.2278867,0.1430148,0.3138672,1.93E-07,yes/no,OR,NA
Loneliness,Anxiety diagnosis,1SMR,ivreg,0.13,-0.04,0.29,1.30E-01,yes/no,RD,NA
Loneliness,Anxiety diagnosis,2SMR,IVW,0.2810334,-0.07572071,0.6394865,1.20E-01,NA,OR,NA
Loneliness,Depression trait,Obs,linear,0.5815217,0.55706522,0.6032609,0.00E+00,score ranges from 0 to 27,cont,3.68
Loneliness,Depression trait,Between-family,linear,0.6956522,0.60326087,0.7880435,1.20E-48,score ranges from 0 to 27,cont,3.68
Loneliness,Depression trait,Within-family,linear,0.4809783,0.38315217,0.576087,1.39E-22,score ranges from 0 to 27,cont,3.68
Loneliness,Depression trait,1SMR,ivreg,3.5326087,2.07065217,4.9918478,2.16E-06,score ranges from 0 to 27,cont,3.68
Loneliness,Depression trait,2SMR,NA,NA,NA,NA,NA,NA,NA,3.68
Loneliness,Anxiety trait,Obs,linear,0.4705882,0.44705882,0.4941176,0.00E+00,score ranges from 0 to 21,cont,3.4
Loneliness,Anxiety trait,Between-family,linear,0.5735294,0.48235294,0.6676471,2.61E-33,score ranges from 0 to 21,cont,3.4
Loneliness,Anxiety trait,Within-family,linear,0.3735294,0.27647059,0.4676471,2.51E-14,score ranges from 0 to 21,cont,3.4
Loneliness,Anxiety trait,1SMR,ivreg,2.2970588,1.14117647,3.4529412,9.81E-05,score ranges from 0 to 21,cont,3.4
Loneliness,Anxiety trait,2SMR,NA,NA,NA,NA,NA,NA,NA,3.4
Loneliness,Positive affect,Obs,linear,-0.7027027,-0.71621622,-0.6891892,0.00E+00,rating ranges from 1 to 6,cont,0.74
Loneliness,Positive affect,Between-family,linear,-0.7567568,-0.83783784,-0.6891892,5.58E-85,rating ranges from 1 to 6,cont,0.74
Loneliness,Positive affect,Within-family,linear,-0.5945946,-0.67567568,-0.527027,9.56E-52,rating ranges from 1 to 6,cont,0.74
Loneliness,Positive affect,1SMR,ivreg,-2.3783784,-3.24324324,-1.5135135,7.98E-08,rating ranges from 1 to 6,cont,0.74
Loneliness,Positive affect,2SMR,IVW,-0.472973,-0.62162162,-0.3243243,3.37E-10,NA,cont,0.74
Loneliness,Meaning in Life,Obs,linear,-0.5180723,-0.54216867,-0.4939759,0.00E+00,rating ranges from 1 to 5,cont,0.83
Loneliness,Meaning in Life,Between-family,linear,-0.5903614,-0.6746988,-0.5060241,4.59E-40,rating ranges from 1 to 5,cont,0.83
Loneliness,Meaning in Life,Within-family,linear,-0.4096386,-0.4939759,-0.313253,2.67E-19,rating ranges from 1 to 5,cont,0.83
Loneliness,Meaning in Life,1SMR,ivreg,-1.1084337,-2.09638554,-0.1204819,3.00E-02,rating ranges from 1 to 5,cont,0.83
Loneliness,Meaning in Life,2SMR,NA,NA,NA,NA,NA,NA,NA,0.83
Loneliness,Wellbeing spectrum,Obs,NA,NA,NA,NA,NA,NA,NA,NA
Loneliness,Wellbeing spectrum,Between-family,NA,NA,NA,NA,NA,NA,NA,NA
Loneliness,Wellbeing spectrum,Within-family,NA,NA,NA,NA,NA,NA,NA,NA
Loneliness,Wellbeing spectrum,1SMR,NA,NA,NA,NA,NA,NA,NA,NA
Loneliness,Wellbeing spectrum,2SMR,IVW,-0.28,-0.32,-0.23,4.55E-33,NA,cont,NA
Loneliness,Life satisfaction,Obs,NA,NA,NA,NA,NA,NA,NA,NA
Loneliness,Life satisfaction,Between-family,NA,NA,NA,NA,NA,NA,NA,NA
Loneliness,Life satisfaction,Within-family,NA,NA,NA,NA,NA,NA,NA,NA
Loneliness,Life satisfaction,1SMR,NA,NA,NA,NA,NA,NA,NA,NA
Loneliness,Life satisfaction,2SMR,IVW,-0.47,-0.69,-0.24,4.28E-05,NA,cont,NA

Solution

  • Here is one possible option which uses just one geom_segment and instead of using separate dataframes adds some new columns to your data to draw the segments:

    library(tidyverse)
    
    df_filtered <- df_filtered |>
      mutate(
        Method = factor(Method),
        y = case_when(
          UCI > upper_limit ~ LCI,
          LCI < lower_limit ~ UCI,
          .default = NA
        ),
        yend = case_when(
          UCI > upper_limit ~ Inf,
          LCI < lower_limit ~ -Inf,
          .default = NA
        ),
        x = as.numeric(factor(Outcome)) +
          scales::rescale(
            as.numeric(Method),
            # 4 / 5 = (#(Method) - 1) / #(Method)
            to = c(-1, 1) * (dodge_width / 2) * 4 / 5
          )
      )
    
    ggplot(df_filtered, aes(x = Outcome)) +
      geom_vline(
        data = line_data, aes(xintercept = xintercept),
        color = "white", linetype = 1
      ) +
      geom_hline(yintercept = 0, color = "#646363", linetype = "dashed") +
      geom_point(
        aes(
          y = Effect_estimate, shape = Method,
          color = effect_type, fill = effect_type,
          group = Method
        ),
        size = 3, stroke = 0, position = pd, na.rm = TRUE
      ) +
      geom_errorbar(
        aes(
          ymin = LCI,
          ymax = UCI,
          color = effect_type,
          group = Method
        ),
        width = 0, position = pd, size = 0.5, na.rm = TRUE
      ) +
      geom_segment(
        aes(
          x = x, xend = x,
          y = y, yend = yend,
          color = effect_type
        ),
        arrow = arrow(length = unit(.1, "cm"), type = "open"),
        size = 0.5, na.rm = TRUE
      ) +
      theme_minimal(base_size = 15) +
      theme(
        panel.background = element_rect(fill = "gray90", color = NA), # Gray background
        panel.grid.major.x = element_blank(), # No major vertical gridlines
        panel.grid.minor.x = element_blank(), # Optional: Hide minor vertical gridlines
        panel.grid.major.y = element_line(color = "white"), # Keep y major gridlines
        panel.grid.minor.y = element_line(color = "white", linewidth = 0.5),
        legend.position = "right",
        axis.text.x = element_text(angle = 45, hjust = 1)
      ) +
      labs(
        y = "Effect Estimate (OR)", x = "Outcome",
        color = "Effect Type", fill = "Effect Type", shape = "Method"
      ) +
      scale_fill_manual(
        values = custom_colors, na.translate = FALSE,
        labels = c("cont" = "Continuous")
      ) + # Use custom colors for effect types
      scale_color_manual(
        values = custom_colors, na.translate = FALSE,
        labels = c("cont" = "Continuous")
      ) + # Use custom colors for effect types
      scale_shape_manual(
        values = custom_shapes,
        labels = c("Obs" = "Observational")
      ) +
      scale_y_continuous(
        limits = c(-2.5, 3.8), labels = c("0.14", "1", "7.39", "54.60"),
        sec.axis = sec_axis(
          trans = ~.,
          name = "Effect Estimate (Continuous, RD)"
        )
      ) +
      guides(shape = guide_legend(
        override.aes = list(
          fill = "black",
          stroke = 0
        )
      ))
    

    enter image description here