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 exercise, 14(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 sports, 16(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 – open, 7(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 metabolisme, 46(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:
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