Conclusion

Conclusion

Overall, our meta‑analysis suggests that mask‑wearing during exercise has only small effects on heart rate and lactate, with no consistent evidence of clinically important cardiovascular or metabolic stress across mask types or fitness levels. In contrast, there is a modest but robust reduction in exercise performance and a clear, consistent increase in perceived exertion across both healthy and trained groups.

Oxygen saturation typically falls slightly under masked conditions but usually remains within normal ranges in healthy and trained adults. Trained individuals tend to show more consistent responses and lower heterogeneity than healthy groups, which may reflect better adaptation to exercise in general, while FFP2/N95 respirators often show the largest effect sizes in healthy populations but with unstable estimates due to high variability.

From a practical standpoint, these findings support the idea that masks can be used safely for most healthy exercisers and athletes in typical settings, with the main trade‑off being reduced comfort and modest performance impairment, particularly for tasks requiring maximal output. Our visual analytics approach helps make these trade‑offs transparent by linking effect sizes, heterogeneity and potential biases into a single, interpretable narrative for students, practitioners and policy-makers.

References

Borg G. A. (1982). Psychophysical bases of perceived exertion. Medicine and science in sports and exercise14(5), 377–381.

Borg, E., & Kaijser, L. (2006). A comparison between three rating scales for perceived exertion and two different work tests. Scandinavian journal of medicine & science in sports16(1), 57–69. https://doi.org/10.1111/j.1600-0838.2005.00448.x

Engeroff, T., Groneberg, D. A., & Niederer, D. (2021). The Impact of Ubiquitous Face Masks and Filtering Face Piece Application During Rest, Work and Exercise on Gas Exchange, Pulmonary Function and Physical Performance: A Systematic Review with Meta-analysis. Sports medicine – open7(1), 92. https://doi.org/10.1186/s40798-021-00388-6

Shaw, K. A., Zello, G. A., Butcher, S. J., Ko, J. B., Bertrand, L., & Chilibeck, P. D. (2021). The impact of face masks on performance and physiological outcomes during exercise: a systematic review and meta-analysis. Applied physiology, nutrition, and metabolism = Physiologie appliquee, nutrition et metabolisme46(7), 693–703. https://doi.org/10.1139/apnm-2021-0143

Zheng, C., Poon, E. T., Wan, K., Dai, Z., & Wong, S. H. (2023). Effects of Wearing a Mask During Exercise on Physiological and Psychological Outcomes in Healthy Individuals: A Systematic Review and Meta-Analysis. Sports medicine (Auckland, N.Z.)53(1), 125–150. https://doi.org/10.1007/s40279-022-01746-4

Visualisation Code

Interaction Visualisation:

https://az2901.github.io/mask

Other diagnostic plots:

# load packages
library(meta)
library(metafor)
library(dplyr)
library(ggplot2)
library(readxl)

# ==============================================================================
# Read data from Excel and run meta-analysis with multiple plots
# ==============================================================================

run_meta_analysis_excel <- function(file_path, sheet_name, outcome_name, col_mapping, effect_measure = "MD") {
  
  # 1. Read Excel data
  raw_data <- read_excel(file_path, sheet = sheet_name, skip = 2, col_names = FALSE)
  
  # Tansfrom into data frame
  raw_data <- as.data.frame(raw_data) 
  
  df <- data.frame(
    Study      = raw_data[[col_mapping["Study"]]],
    Type       = raw_data[[col_mapping["Type"]]],
    Population = raw_data[[col_mapping["Population"]]],
    Int_Mean   = as.numeric(raw_data[[col_mapping["Int_Mean"]]]),
    Int_SD     = as.numeric(raw_data[[col_mapping["Int_SD"]]]),
    Int_N      = as.numeric(raw_data[[col_mapping["Int_N"]]]),
    Ctrl_Mean  = as.numeric(raw_data[[col_mapping["Ctrl_Mean"]]]),
    Ctrl_SD    = as.numeric(raw_data[[col_mapping["Ctrl_SD"]]]),
    Ctrl_N     = as.numeric(raw_data[[col_mapping["Ctrl_N"]]])
  )
  
  # 3. Clean the data
  df_clean <- df %>%
    mutate(Population = trimws(Population)) %>% 
    filter(!is.na(Int_Mean) & !is.na(Ctrl_Mean) & !is.na(Int_SD) & !is.na(Ctrl_SD)) %>%
    filter(Population %in% c("Healthy", "Trained")) %>%
    mutate(Study_Label = paste(Study, "(", Type, ")"))
  
  if (nrow(df_clean) == 0) {
    stop(paste("The data for", outcome_name, "is empty after cleaning. Please check Excel format."))
  }
  
  # RPE data transformation: Convert all RPE scores to a common 0-100 scale
  if (outcome_name == "RPE") {
    df_clean <- df_clean %>%
      mutate(
        Scale_Type = case_when(
          grepl("Dantas", Study, ignore.case = TRUE) ~ "VAS100",
          Ctrl_Mean > 10 & Ctrl_Mean <= 20 ~ "Borg20",
          Ctrl_Mean <= 10 ~ "Borg10",
          TRUE ~ "VAS100"
        ),
        # transform means to 0-100 scale
        Int_Mean = case_when(
          Scale_Type == "Borg10" ~ Int_Mean * 10,
          Scale_Type == "Borg20" ~ (Int_Mean - 6) / 14 * 100,
          Scale_Type == "VAS100" ~ Int_Mean
        ),
        Ctrl_Mean = case_when(
          Scale_Type == "Borg10" ~ Ctrl_Mean * 10,
          Scale_Type == "Borg20" ~ (Ctrl_Mean - 6) / 14 * 100,
          Scale_Type == "VAS100" ~ Ctrl_Mean
        ),
        # transform SDs to 0-100 scale
        Int_SD = case_when(
          Scale_Type == "Borg10" ~ Int_SD * 10,
          Scale_Type == "Borg20" ~ Int_SD / 14 * 100,
          Scale_Type == "VAS100" ~ Int_SD
        ),
        Ctrl_SD = case_when(
          Scale_Type == "Borg10" ~ Ctrl_SD * 10,
          Scale_Type == "Borg20" ~ Ctrl_SD / 14 * 100,
          Scale_Type == "VAS100" ~ Ctrl_SD
        )
      )
    effect_measure <- "MD" 
  }
  # =========================================================================
  
  # 4. Run meta-analysis
  meta_results <- metacont(
    n.e = Int_N, mean.e = Int_Mean, sd.e = Int_SD,
    n.c = Ctrl_N, mean.c = Ctrl_Mean, sd.c = Ctrl_SD,
    data = df_clean,
    studlab = Study_Label,
    sm = effect_measure,   
    method.tau = "REML",
    subgroup = Population,
    label.e = "Mask On",
    label.c = "Mask Off"
  )
  
  # Come up with the plots
  prefix <- outcome_name
  
  # Plot 1: Forest Plot
  png(paste0(prefix, "_1_Forest_Plot.png"), width = 2200, height = 2600, res = 150)
  forest(meta_results, sortvar = TE, col.square = "blue", col.diamond = "red",
         leftcols = c("studlab", "n.e", "mean.e", "sd.e", "n.c", "mean.c", "sd.c"),
         leftlabs = c("Study", "N", "Mean", "SD", "N", "Mean", "SD"),
         text.random = "Overall Random Effects Model", print.subgroup.name = FALSE,
         smlab = paste("Effect Size (Mask On vs Off)\n", outcome_name))
  dev.off()
  
  # Plot 2: Funnel Plot
  png(paste0(prefix, "_2_Funnel_Plot.png"), width = 800, height = 800, res = 150)
  funnel(meta_results, xlab = "Effect Size", ylab = "Standard Error",
         col = ifelse(df_clean$Population == "Healthy", "steelblue", "darkorange"),
         bg =  ifelse(df_clean$Population == "Healthy", "steelblue", "darkorange"),
         pch = 21, cex = 1.5, studlab = FALSE)
  legend("topright", legend = c("Healthy", "Trained"), 
         col = c("steelblue", "darkorange"), pt.bg = c("steelblue", "darkorange"), pch = 21)
  title(paste("Funnel Plot:", outcome_name))
  dev.off()
  
  # Plot 3: L'Abbé-style Plot
  png(paste0(prefix, "_3_Labbe_Style_Plot.png"), width = 1000, height = 1000, res = 150)
  min_val <- min(c(df_clean$Ctrl_Mean, df_clean$Int_Mean), na.rm = TRUE)
  max_val <- max(c(df_clean$Ctrl_Mean, df_clean$Int_Mean), na.rm = TRUE)
  padding <- (max_val - min_val) * 0.1
  
  p3 <- ggplot(df_clean, aes(x = Ctrl_Mean, y = Int_Mean)) +
    geom_point(aes(fill = Population), shape = 21, color = "black", size = 4, alpha = 0.8) +
    geom_abline(intercept = 0, slope = 1, color = "red", linetype = "dashed", linewidth = 1) +
    scale_fill_manual(values = c("Healthy" = "steelblue", "Trained" = "darkorange")) +
    coord_fixed(xlim = c(min_val - padding, max_val + padding), ylim = c(min_val - padding, max_val + padding)) +
    labs(title = paste("L'Abbé-style Plot:", outcome_name),
         subtitle = "Red dashed line represents y = x (No difference)",
         x = paste("Control Group Mean (Mask Off) -", outcome_name),
         y = paste("Intervention Group Mean (Mask On) -", outcome_name),
         fill = "Subgroup") +
    theme_minimal() +
    theme(plot.title = element_text(hjust = 0.5, face = "bold"),
          plot.subtitle = element_text(hjust = 0.5), legend.position = "bottom")
  print(p3)
  dev.off()
  
  # Plot 4: Baujat Plot
  png(paste0(prefix, "_4_Baujat_Plot.png"), width = 800, height = 800, res = 150)
  baujat(meta_results, xlab = "Contribution to heterogeneity", ylab = "Influence on result", cex = 1.2)
  title(paste("Baujat Plot:", outcome_name))
  dev.off()
  
  # Plot 5: Radial Plot
  png(paste0(prefix, "_5_Radial_Plot.png"), width = 800, height = 800, res = 150)
  radial(meta_results, cex = 1.5, col = "purple")
  title(paste("Radial Plot:", outcome_name))
  dev.off()
  
  # Plot 6: Bubble Plot
  meta_reg <- metareg(meta_results, ~ Ctrl_Mean)
  png(paste0(prefix, "_6_Bubble_Plot.png"), width = 900, height = 700, res = 150)
  bubble(meta_reg, xlab = paste("Baseline Control Mean (Mask Off) -", outcome_name), 
         ylab = "Effect Size", col.line = "red", bg = "lightblue", cex = 1.5)
  title(paste("Bubble Plot: Meta-Regression by Baseline", outcome_name))
  dev.off()
  
  cat("==>", outcome_name, "Processing Completed!\n")
}


# ==============================================================================
# Execution Section
# ==============================================================================

# 【1】 Heart Rate 
hr_cols <- c(Study = 1, Type = 2, Population = 3, 
             Int_Mean = 6, Int_SD = 7, Int_N = 8, 
             Ctrl_Mean = 10, Ctrl_SD = 11, Ctrl_N = 12)

run_meta_analysis_excel(file_path = "HeartRate.xlsx", 
                        sheet_name = "Sheet1", 
                        outcome_name = "HeartRate", 
                        col_mapping = hr_cols, 
                        effect_measure = "MD") 

# 【2】Lactate
lactate_cols <- c(Study = 1, Type = 2, Population = 4, 
                  Int_Mean = 5, Int_SD = 6, Int_N = 7, 
                  Ctrl_Mean = 9, Ctrl_SD = 10, Ctrl_N = 11)

run_meta_analysis_excel(file_path = "lactate.xlsx", 
                        sheet_name = "Sheet1", 
                        outcome_name = "Lactate", 
                        col_mapping = lactate_cols, 
                        effect_measure = "SMD") 

# Similar structure for other metrics