ECG/EKG 12-Lead Waveform Display — Chart.js

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 Chart.js

JavaScript source (Chart.js)

// anyplot.ai
// ecg-twelve-lead: ECG/EKG 12-Lead Waveform Display
// Library: chartjs 4.4.7 | JavaScript 22.22.3
// Quality: 93/100 | Created: 2026-06-17
//# anyplot-orientation: landscape

const t = window.ANYPLOT_TOKENS;
const TRACE = t.palette[0]; // #009E73 Imprint brand green — the single signal series

// Theme-adaptive ECG "paper" grid (light red / pink), drawn over the warm
// page background instead of pure white so it stays on the Imprint surface.
const isDark = window.ANYPLOT_THEME === "dark";
const GRID_MINOR = isDark ? "rgba(199,90,90,0.16)" : "rgba(174,48,48,0.13)";
const GRID_MAJOR = isDark ? "rgba(199,90,90,0.34)" : "rgba(174,48,48,0.32)";

// --- Synthetic 12-lead signal (normal sinus rhythm, ~75 bpm) ----------------
// Each lead is a sum of Gaussian P-Q-R-S-T deflections (mV); per-lead amplitudes
// encode the normal cardiac axis (aVR inverted, V1 rS → V6 qR progression).
const LEADS = {
  I:   { p: 0.12, q: -0.04, r: 0.75, s: -0.10, t: 0.26 },
  II:  { p: 0.17, q: -0.04, r: 1.15, s: -0.12, t: 0.34 },
  III: { p: 0.08, q: -0.03, r: 0.55, s: -0.14, t: 0.16 },
  aVR: { p: -0.10, q: 0.03, r: -0.55, s: 0.05, t: -0.20 },
  aVL: { p: 0.07, q: -0.03, r: 0.42, s: -0.08, t: 0.14 },
  aVF: { p: 0.12, q: -0.03, r: 0.62, s: -0.10, t: 0.22 },
  V1:  { p: 0.09, q: 0.0, r: 0.28, s: -0.95, t: -0.12 },
  V2:  { p: 0.11, q: 0.0, r: 0.45, s: -1.25, t: 0.32 },
  V3:  { p: 0.12, q: -0.02, r: 0.80, s: -0.85, t: 0.42 },
  V4:  { p: 0.13, q: -0.04, r: 1.35, s: -0.50, t: 0.48 },
  V5:  { p: 0.13, q: -0.05, r: 1.25, s: -0.25, t: 0.44 },
  V6:  { p: 0.12, q: -0.05, r: 0.95, s: -0.12, t: 0.34 },
};

// Deterministic tiny baseline noise (fixed-seed LCG — no Math.random in harness).
let seed = 12345;
const noise = () => {
  seed = (1103515245 * seed + 12345) % 2147483648;
  return (seed / 2147483648 - 0.5) * 0.012;
};

const BEAT = 0.8; // seconds per cardiac cycle → 75 bpm
const gauss = (ph, mu, sig) => Math.exp(-((ph - mu) * (ph - mu)) / (2 * sig * sig));
function voltage(cfg, time) {
  const ph = time % BEAT;
  return (
    cfg.p * gauss(ph, 0.12, 0.022) +
    cfg.q * gauss(ph, 0.232, 0.0085) +
    cfg.r * gauss(ph, 0.252, 0.011) +
    cfg.s * gauss(ph, 0.272, 0.011) +
    cfg.t * gauss(ph, 0.40, 0.040) +
    noise()
  );
}

// --- Layout geometry (millimetre paper space; 25 mm/s, 10 mm/mV) ------------
// Standard clinical 3×4 grid + a full-length Lead II rhythm strip beneath it.
const GRID = [
  ["I", "aVR", "V1", "V4"],
  ["II", "aVL", "V2", "V5"],
  ["III", "aVF", "V3", "V6"],
];
const LEFT = 10;        // left margin (holds the calibration pulse)
const STRIP_MM = 62.5;  // 2.5 s column at 25 mm/s
const MM_PER_S = 25;
const MM_PER_MV = 10;
const ROW_Y = [122, 88, 54]; // baselines, top → bottom
const RHYTHM_Y = 20;
const X_MAX = LEFT + 4 * STRIP_MM + 5; // 265
const Y_MAX = 136.2; // tuned so 1 mm paper squares stay square at 16:9
const DT = 0.002;

// One cell of the grid: a 2.5 s window of the continuous recording.
function cellTrace(lead, col, baseline) {
  const cfg = LEADS[lead];
  const t0 = 2.5 * col;
  const pts = [];
  for (let time = t0; time <= t0 + 2.5 + 1e-9; time += DT) {
    pts.push({ x: LEFT + col * STRIP_MM + (time - t0) * MM_PER_S, y: baseline + voltage(cfg, time) * MM_PER_MV });
  }
  return pts;
}

const datasets = [];
GRID.forEach((row, r) =>
  row.forEach((lead, c) => datasets.push({ data: cellTrace(lead, c, ROW_Y[r]) }))
);

// Full 10 s Lead II rhythm strip across the bottom.
const rhythm = [];
for (let time = 0; time <= 10 + 1e-9; time += DT) {
  rhythm.push({ x: LEFT + time * MM_PER_S, y: RHYTHM_Y + voltage(LEADS.II, time) * MM_PER_MV });
}
datasets.push({ data: rhythm });

// 1 mV calibration pulse at the left margin of every row.
const calPulse = (b) => [
  { x: 1, y: b }, { x: 3.5, y: b }, { x: 3.5, y: b + 10 },
  { x: 8.5, y: b + 10 }, { x: 8.5, y: b }, { x: 9.5, y: b },
];
[...ROW_Y, RHYTHM_Y].forEach((b) => datasets.push({ data: calPulse(b) }));

// Shared trace styling — thin crisp green pen, no markers.
datasets.forEach((d) => {
  d.borderColor = TRACE;
  d.borderWidth = 1.3;
  d.pointRadius = 0;
  d.tension = 0;
  d.fill = false;
  d.borderJoinStyle = "round";
});

// --- ECG paper, lead labels, title (custom plugins) -------------------------
const ecgPaper = {
  id: "ecgPaper",
  beforeDatasetsDraw(chart) {
    const { ctx } = chart;
    const xs = chart.scales.x;
    const ys = chart.scales.y;
    const px = (v) => xs.getPixelForValue(v);
    const py = (v) => ys.getPixelForValue(v);

    const drawLines = (step, width, color) => {
      ctx.save();
      ctx.lineWidth = width;
      ctx.strokeStyle = color;
      ctx.beginPath();
      for (let x = 0; x <= X_MAX + 1e-6; x += step) {
        ctx.moveTo(px(x), py(0));
        ctx.lineTo(px(x), py(Y_MAX));
      }
      for (let y = 0; y <= Y_MAX + 1e-6; y += step) {
        ctx.moveTo(px(0), py(y));
        ctx.lineTo(px(X_MAX), py(y));
      }
      ctx.stroke();
      ctx.restore();
    };
    drawLines(1, 1, GRID_MINOR);
    drawLines(5, 1.6, GRID_MAJOR);
  },
  afterDatasetsDraw(chart) {
    const { ctx, chartArea: a } = chart;
    const xs = chart.scales.x;
    const ys = chart.scales.y;
    const px = (v) => xs.getPixelForValue(v);
    const py = (v) => ys.getPixelForValue(v);

    // Lead labels, anchored top-left of each cell.
    ctx.save();
    ctx.fillStyle = t.ink;
    ctx.font = "bold 17px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
    ctx.textAlign = "left";
    ctx.textBaseline = "alphabetic";
    GRID.forEach((row, r) =>
      row.forEach((lead, c) => ctx.fillText(lead, px(LEFT + c * STRIP_MM + 2), py(ROW_Y[r] + 15.5)))
    );
    ctx.fillText("II  ·  rhythm strip", px(LEFT + 2), py(RHYTHM_Y + 15.5));
    ctx.restore();

    // Title in the reserved top band.
    ctx.save();
    ctx.fillStyle = t.ink;
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.font = "bold 22px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
    ctx.fillText("ecg-twelve-lead · javascript · chartjs · anyplot.ai", (a.left + a.right) / 2, a.top / 2);
    ctx.restore();

    // Scale footnote in the reserved bottom band.
    ctx.save();
    ctx.fillStyle = t.inkSoft;
    ctx.textAlign = "right";
    ctx.textBaseline = "middle";
    ctx.font = "13px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
    ctx.fillText(
      "synthetic normal sinus rhythm, 75 bpm  ·  25 mm/s  ·  10 mm/mV  ·  1 mV calibration pulse",
      a.right,
      a.bottom + (chart.height - a.bottom) / 2
    );
    ctx.restore();
  },
};

// --- Mount ------------------------------------------------------------------
const canvas = document.createElement("canvas");
document.getElementById("container").appendChild(canvas);

new Chart(canvas, {
  type: "line",
  data: { datasets },
  options: {
    responsive: true,
    maintainAspectRatio: false,
    animation: false,
    parsing: false,
    layout: { padding: { left: 10, right: 10, top: 58, bottom: 30 } },
    plugins: {
      legend: { display: false },
      title: { display: false },
      tooltip: { enabled: false },
    },
    scales: {
      x: { type: "linear", min: 0, max: X_MAX, display: false },
      y: { type: "linear", min: 0, max: Y_MAX, display: false },
    },
  },
  plugins: [ecgPaper],
});

Part of ECG/EKG 12-Lead Waveform Display on anyplot.ai.

Other implementations