Spirometry Flow-Volume Loop — ggplot2

A pulmonary function test visualization that plots airflow rate (L/s) against lung volume (L) during forced expiration and inspiration, forming a characteristic loop shape. The expiratory limb rises sharply to Peak Expiratory Flow (PEF) then declines, while the inspiratory limb forms a more symmetric curve below the x-axis. This plot is essential for diagnosing obstructive and restrictive lung diseases by comparing measured loops against predicted normal values.

Spirometry Flow-Volume Loop rendered with ggplot2

R source (ggplot2)

#' anyplot.ai
#' spirometry-flow-volume: Spirometry Flow-Volume Loop
#' Library: ggplot2 3.5.1 | R 4.4.1
#' Quality: 91/100 | Created: 2026-06-17

library(ggplot2)
library(dplyr)
library(ragg)

set.seed(42)

# --- Theme tokens -----------------------------------------------------------
THEME       <- Sys.getenv("ANYPLOT_THEME", "light")
PAGE_BG     <- if (THEME == "light") "#FAF8F1" else "#1A1A17"
ELEVATED_BG <- if (THEME == "light") "#FFFDF6" else "#242420"
INK         <- if (THEME == "light") "#1A1A17" else "#F0EFE8"
INK_SOFT    <- if (THEME == "light") "#4A4A44" else "#B8B7B0"
INK_MUTED   <- if (THEME == "light") "#6B6A63" else "#A8A79F"
GRID        <- if (THEME == "light") "#1A1A17" else "#F0EFE8"

BRAND <- "#009E73"  # Imprint palette position 1 — measured loop (first series)
MUTED <- INK_MUTED  # Imprint semantic anchor "muted" — predicted normal reference

# --- Data -------------------------------------------------------------------
# Forced spirometry maneuver: airflow (L/s) vs exhaled lung volume (L).
# Expiratory limb rises sharply to Peak Expiratory Flow (PEF) then declines
# roughly linearly; inspiratory limb is a symmetric U below the zero-flow line.
flow_volume_loop <- function(fvc, pef, pif, v_peak) {
    # Expiratory limb: 0 -> FVC, sharp rise to PEF then near-linear decline.
    v_rise <- seq(0, v_peak, length.out = 50)
    f_rise <- pef * (v_rise / v_peak)^0.55
    v_fall <- seq(v_peak, fvc, length.out = 150)[-1]
    f_fall <- pef * (1 - (v_fall - v_peak) / (fvc - v_peak))^1.15

    # Inspiratory limb: FVC -> 0, symmetric U-shaped curve (negative flow).
    v_insp <- seq(fvc, 0, length.out = 200)
    f_insp <- -pif * sin(pi * v_insp / fvc)^0.9

    data.frame(
        volume = c(v_rise, v_fall, v_insp),
        flow   = c(f_rise, f_fall, f_insp)
    )
}

# Predicted normal loop (reference) and the patient's measured loop.
predicted <- flow_volume_loop(fvc = 5.1, pef = 10.4, pif = 6.0, v_peak = 0.55) %>%
    mutate(series = "Predicted normal")
measured <- flow_volume_loop(fvc = 4.6, pef = 9.1, pif = 5.2, v_peak = 0.65) %>%
    mutate(series = "Measured")

loop_df <- bind_rows(measured, predicted) %>%
    mutate(series = factor(series, levels = c("Measured", "Predicted normal")))

# Peak Expiratory Flow marker (highest flow on the measured loop).
pef_point <- measured %>% slice_max(flow, n = 1)

# Clinical summary values for the measured maneuver.
clinical <- paste(
    "FVC  = 4.60 L",
    "FEV1 = 3.55 L",
    "PEF  = 9.10 L/s",
    sep = "\n"
)

# --- Plot -------------------------------------------------------------------
p <- ggplot(loop_df, aes(volume, flow, color = series, linetype = series)) +
    geom_hline(yintercept = 0, color = INK_SOFT, linewidth = 0.4) +
    geom_path(linewidth = 1.3, lineend = "round") +
    geom_point(
        data = pef_point, aes(volume, flow),
        inherit.aes = FALSE, color = BRAND, size = 3.6
    ) +
    annotate(
        "text", x = pef_point$volume + 0.18, y = pef_point$flow + 0.05,
        label = "PEF", hjust = 0, vjust = 0.5,
        color = INK, size = 4.2, fontface = "bold"
    ) +
    annotate(
        "label", x = 4.55, y = 8.6, label = clinical,
        hjust = 1, vjust = 1, color = INK, fill = ELEVATED_BG,
        label.size = 0.3, size = 4.0, lineheight = 1.25, family = "mono"
    ) +
    scale_color_manual(values = c("Measured" = BRAND, "Predicted normal" = MUTED)) +
    scale_linetype_manual(values = c("Measured" = "solid", "Predicted normal" = "dashed")) +
    scale_x_continuous(breaks = seq(0, 5, 1), expand = expansion(mult = 0.02)) +
    scale_y_continuous(breaks = seq(-6, 10, 2)) +
    labs(
        title = "spirometry-flow-volume · r · ggplot2 · anyplot.ai",
        x = "Volume (L)", y = "Flow (L/s)",
        color = NULL, linetype = NULL
    ) +
    theme_minimal(base_size = 8) +
    theme(
        plot.background  = element_rect(fill = PAGE_BG, color = PAGE_BG),
        panel.background = element_rect(fill = PAGE_BG, color = NA),
        panel.grid.major = element_line(color = GRID, linewidth = 0.25),
        panel.grid.minor = element_blank(),
        axis.title       = element_text(color = INK, size = 11),
        axis.title.x     = element_text(margin = margin(t = 8)),
        axis.title.y     = element_text(margin = margin(r = 8)),
        axis.text        = element_text(color = INK_SOFT, size = 9),
        axis.line        = element_line(color = INK_SOFT, linewidth = 0.4),
        plot.title       = element_text(color = INK, size = 12, margin = margin(b = 12)),
        plot.margin      = margin(18, 22, 14, 16),
        legend.position  = "inside",
        legend.position.inside = c(0.99, 0.02),
        legend.justification = c(1, 0),
        legend.background = element_rect(fill = ELEVATED_BG, color = INK_SOFT, linewidth = 0.3),
        legend.margin    = margin(6, 10, 6, 10),
        legend.text      = element_text(color = INK_SOFT, size = 10),
        legend.key       = element_blank()
    )

# --- Save -------------------------------------------------------------------
ggsave(
    filename = sprintf("plot-%s.png", THEME),
    plot     = p,
    device   = ragg::agg_png,
    width    = 8,
    height   = 4.5,
    units    = "in",
    dpi      = 400
)

Part of Spirometry Flow-Volume Loop on anyplot.ai.

Other implementations