ECG/EKG 12-Lead Waveform Display — ggplot2

A multi-channel electrocardiogram display showing the 12 standard ECG leads arranged in clinical format. Each lead displays realistic P-QRS-T wave complexes on a medical-standard grid background with calibration markers. This visualization replicates the familiar layout used in hospitals and clinics worldwide, making it immediately recognizable to healthcare professionals and useful for medical education.

ECG/EKG 12-Lead Waveform Display rendered with ggplot2

R source (ggplot2)

#' anyplot.ai
#' ecg-twelve-lead: ECG/EKG 12-Lead Waveform Display
#' Library: ggplot2 3.5.1 | R 4.4.1
#' Quality: 93/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"
INK      <- if (THEME == "light") "#1A1A17" else "#F0EFE8"
INK_SOFT <- if (THEME == "light") "#4A4A44" else "#B8B7B0"

# Imprint palette — first series is always brand green; the ECG trace is the
# single data series, so it renders in position 1 (#009E73).
TRACE <- "#009E73"

# ECG paper grid uses the classic medical red/pink, derived from the Imprint
# matte-red anchor (#AE3030). Lighter on the dark surface so it stays visible.
GRID_COL    <- if (THEME == "light") "#AE3030" else "#D98C8C"
MINOR_ALPHA <- if (THEME == "light") 0.16 else 0.24
MAJOR_ALPHA <- if (THEME == "light") 0.38 else 0.50

# --- ECG model --------------------------------------------------------------
# Standard scale: 25 mm/s (time) and 10 mm/mV (voltage). We work in millimetre
# coordinates so that one small grid box (1 mm) equals 0.04 s and 0.1 mV, and
# coord_fixed keeps the boxes square — the authentic ECG-paper look.
MM_PER_S  <- 25      # horizontal calibration
MM_PER_MV <- 10      # vertical calibration
RR        <- 60 / 72 # normal sinus rhythm, ~72 bpm

# Single-beat P-QRS-T morphology built from Gaussian components. The phase
# wraps with the RR interval so every beat in a strip is identical.
strip_t <- seq(0, 2.5, length.out = 2500)   # 2.5 s per cell at 1000 Hz
strip_ph <- ((strip_t - 0.30 + RR / 2) %% RR) - RR / 2

# Per-lead wave amplitudes (mV) reproduce normal precordial/limb morphology:
# rS pattern in V1-V2, tall R in V4-V6, fully inverted aVR, etc.
leads <- tibble::tribble(
    ~lead, ~row, ~col, ~p, ~q, ~r, ~s, ~tw,
    "I", 1L, 1L, 0.10, -0.04, 0.85, -0.10, 0.22,
    "aVR", 1L, 2L, -0.10, 0.00, -0.75, 0.00, -0.22,
    "V1", 1L, 3L, 0.08, 0.00, 0.30, -0.85, -0.12,
    "V4", 1L, 4L, 0.12, -0.08, 1.45, -0.30, 0.38,
    "II", 2L, 1L, 0.16, -0.04, 1.20, -0.10, 0.32,
    "aVL", 2L, 2L, 0.06, -0.03, 0.48, -0.16, 0.13,
    "V2", 2L, 3L, 0.10, 0.00, 0.45, -1.25, 0.24,
    "V5", 2L, 4L, 0.12, -0.10, 1.35, -0.22, 0.36,
    "III", 3L, 1L, 0.09, 0.00, 0.62, -0.05, 0.16,
    "aVF", 3L, 2L, 0.13, -0.03, 0.88, -0.08, 0.24,
    "V3", 3L, 3L, 0.12, -0.05, 0.85, -0.85, 0.30,
    "V6", 3L, 4L, 0.10, -0.08, 1.15, -0.16, 0.32
)

row_center <- c(128, 86, 44)        # mm, top row highest
col_x <- function(c) (c - 1) * (2.5 * MM_PER_S)  # 62.5 mm per cell

# Build the 12 lead strips into one long data frame.
strip_list <- vector("list", nrow(leads))
for (i in seq_len(nrow(leads))) {
    lp <- leads[i, ]
    wave <- lp$p * exp(-0.5 * ((strip_ph + 0.190) / 0.022)^2) +
        lp$q * exp(-0.5 * ((strip_ph + 0.025) / 0.0085)^2) +
        lp$r * exp(-0.5 * ((strip_ph + 0.000) / 0.011)^2) +
        lp$s * exp(-0.5 * ((strip_ph - 0.028) / 0.011)^2) +
        lp$tw * exp(-0.5 * ((strip_ph - 0.200) / 0.045)^2)
    wave <- wave + rnorm(length(strip_t), 0, 0.008)
    strip_list[[i]] <- tibble::tibble(
        x = col_x(lp$col) + strip_t * MM_PER_S,
        y = row_center[lp$row] + wave * MM_PER_MV,
        lead = lp$lead
    )
}
ecg <- bind_rows(strip_list)

# Full-length Lead II rhythm strip across the bottom (10 s continuous).
rhythm_t <- seq(0, 10, length.out = 10000)
rhythm_ph <- ((rhythm_t - 0.30 + RR / 2) %% RR) - RR / 2
rhythm_wave <- 0.16 * exp(-0.5 * ((rhythm_ph + 0.190) / 0.022)^2) +
    -0.04 * exp(-0.5 * ((rhythm_ph + 0.025) / 0.0085)^2) +
    1.20 * exp(-0.5 * ((rhythm_ph + 0.000) / 0.011)^2) +
    -0.10 * exp(-0.5 * ((rhythm_ph - 0.028) / 0.011)^2) +
    0.32 * exp(-0.5 * ((rhythm_ph - 0.200) / 0.045)^2)
rhythm_wave <- rhythm_wave + rnorm(length(rhythm_t), 0, 0.008)
rhythm <- tibble::tibble(
    x = rhythm_t * MM_PER_S,
    y = 12 + rhythm_wave * MM_PER_MV,
    lead = "II"
)

# 1 mV calibration pulses (a 10 mm step) at the left margin of every row.
cal_centers <- c(row_center, 12)
cal_list <- vector("list", length(cal_centers))
for (i in seq_along(cal_centers)) {
    cal_list[[i]] <- tibble::tibble(
        x = c(-9, -6, -6, -3, -3, -0.5),
        y = cal_centers[i] + c(0, 0, 10, 10, 0, 0),
        grp = i
    )
}
cal <- bind_rows(cal_list)

# Lead name labels at the top-left of each cell.
labels <- tibble::tibble(
    x = col_x(leads$col) + 2,
    y = row_center[leads$row] + 16,
    lead = leads$lead
)
labels <- bind_rows(labels, tibble::tibble(x = 2, y = 12 + 16, lead = "II"))

# Grid coordinates (1 mm minor, 5 mm major) spanning the full display.
x_lo <- -11
x_hi <- 251
y_lo <- -3
y_hi <- 151
minor_x <- seq(-10, 250, by = 1)
minor_y <- seq(0, 150, by = 1)
major_x <- seq(-10, 250, by = 5)
major_y <- seq(0, 150, by = 5)

# --- Plot -------------------------------------------------------------------
p <- ggplot() +
    geom_vline(xintercept = minor_x, color = GRID_COL, linewidth = 0.12, alpha = MINOR_ALPHA) +
    geom_hline(yintercept = minor_y, color = GRID_COL, linewidth = 0.12, alpha = MINOR_ALPHA) +
    geom_vline(xintercept = major_x, color = GRID_COL, linewidth = 0.30, alpha = MAJOR_ALPHA) +
    geom_hline(yintercept = major_y, color = GRID_COL, linewidth = 0.30, alpha = MAJOR_ALPHA) +
    geom_path(data = cal, aes(x = x, y = y, group = grp), color = INK, linewidth = 0.5) +
    geom_path(data = ecg, aes(x = x, y = y, group = lead), color = TRACE, linewidth = 0.42) +
    geom_path(data = rhythm, aes(x = x, y = y), color = TRACE, linewidth = 0.42) +
    geom_text(
        data = labels, aes(x = x, y = y, label = lead),
        color = INK, fontface = "bold", size = 4, hjust = 0
    ) +
    labs(
        title = "ecg-twelve-lead · r · ggplot2 · anyplot.ai",
        subtitle = "Synthetic normal sinus rhythm (~72 bpm) · 25 mm/s · 10 mm/mV · 1 mV calibration"
    ) +
    coord_fixed(ratio = 1, xlim = c(x_lo, x_hi), ylim = c(y_lo, y_hi), expand = FALSE) +
    theme_void(base_size = 8) +
    theme(
        plot.background = element_rect(fill = PAGE_BG, color = PAGE_BG),
        panel.background = element_rect(fill = PAGE_BG, color = NA),
        plot.title = element_text(color = INK, size = 13, face = "bold", hjust = 0.5,
                                  margin = margin(b = 4)),
        plot.subtitle = element_text(color = INK_SOFT, size = 8.5, hjust = 0.5,
                                     margin = margin(b = 8)),
        plot.margin = margin(14, 18, 14, 18)
    )

# --- 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 ECG/EKG 12-Lead Waveform Display on anyplot.ai.

Other implementations