ECG/EKG 12-Lead Waveform Display — Apache ECharts

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 Apache ECharts

JavaScript source (Apache ECharts)

// anyplot.ai
// ecg-twelve-lead: ECG/EKG 12-Lead Waveform Display
// Library: echarts 5.5.1 | JavaScript 22.22.3
// Quality: 91/100 | Created: 2026-06-17
//# anyplot-orientation: landscape
// anyplot.ai
// ecg-twelve-lead: ECG/EKG 12-Lead Waveform Display
// Library: echarts 5.5.1 | JavaScript 22
// Quality: pending | Created: 2026-06-17

const t = window.ANYPLOT_TOKENS;
const THEME = window.ANYPLOT_THEME;
const W = window.ANYPLOT_SIZE.width;
const H = window.ANYPLOT_SIZE.height;

// --- Theme-adaptive chrome --------------------------------------------------
// The waveform trace is a single signal type repeated across all 12 leads, so it
// uses the Imprint brand green (palette[0]). The ECG paper grid keeps its
// real-world domain convention — a light red/pink ruling — derived from the
// Imprint matte-red anchor, tuned per theme for contrast on cream / near-black.
const TRACE = t.palette[0]; // #009E73 — brand green, first (and only) series colour
const MAJOR_GRID = THEME === "light" ? "rgba(199,68,68,0.42)" : "rgba(228,108,108,0.34)";
const MINOR_GRID = THEME === "light" ? "rgba(199,68,68,0.16)" : "rgba(228,108,108,0.12)";

// --- ECG paper geometry -----------------------------------------------------
// Standard scale: 25 mm/s horizontal, 10 mm/mV vertical. One big square (5 mm)
// spans 0.20 s and 0.5 mV; small squares (1 mm) subdivide it 5x. A continuous
// coordinate system carries all 12 leads + the rhythm strip so the paper ruling
// is unbroken, exactly like a printed clinical ECG.
const RR = 0.8; // 75 bpm — normal sinus rhythm
const COL_SEC = 2.5; // seconds shown per lead column (2.5 s @ 25 mm/s)
const DT = 0.004; // sampling step for the rendered trace

// Lead morphology — per-lead {P, Q, R, S, T} amplitudes in mV. Q/S are negative.
// aVR is globally inverted; precordials show R-wave progression (V1 deep rS to
// V5/V6 tall qR), the textbook normal pattern.
const PROFILES = {
  I: { p: 0.10, q: -0.05, r: 0.70, s: -0.08, t: 0.20 },
  II: { p: 0.15, q: -0.05, r: 1.20, s: -0.10, t: 0.30 },
  III: { p: 0.07, q: -0.04, r: 0.55, s: -0.10, t: 0.15 },
  aVR: { p: -0.10, q: 0.05, r: -0.55, s: 0.04, t: -0.20 },
  aVL: { p: 0.05, q: -0.05, r: 0.45, s: -0.10, t: 0.12 },
  aVF: { p: 0.11, q: -0.04, r: 0.80, s: -0.08, t: 0.22 },
  V1: { p: 0.08, q: 0.0, r: 0.25, s: -0.90, t: -0.10 },
  V2: { p: 0.10, q: 0.0, r: 0.45, s: -1.30, t: 0.35 },
  V3: { p: 0.10, q: -0.03, r: 0.80, s: -0.90, t: 0.45 },
  V4: { p: 0.12, q: -0.05, r: 1.35, s: -0.50, t: 0.50 },
  V5: { p: 0.12, q: -0.06, r: 1.45, s: -0.25, t: 0.45 },
  V6: { p: 0.11, q: -0.06, r: 1.20, s: -0.12, t: 0.35 },
};

// Standard clinical 3x4 arrangement; a full-length Lead II rhythm strip runs
// across the bottom.
const LAYOUT = [
  ["I", "aVR", "V1", "V4"],
  ["II", "aVL", "V2", "V5"],
  ["III", "aVF", "V3", "V6"],
];
const ROW_BASELINE = [9, 6, 3]; // mV offset for each grid row
const RHYTHM_BASELINE = 0; // mV offset for the bottom rhythm strip

// One P-QRS-T complex modelled as a sum of Gaussians (tt = seconds into a beat).
const gauss = (x, c, w) => Math.exp(-((x - c) * (x - c)) / (2 * w * w));
function ecgBeat(tt, prof) {
  return (
    prof.p * gauss(tt, 0.17, 0.022) +
    prof.q * gauss(tt, 0.330, 0.0085) +
    prof.r * gauss(tt, 0.365, 0.011) +
    prof.s * gauss(tt, 0.405, 0.012) +
    prof.t * gauss(tt, 0.560, 0.045)
  );
}

// Sample one lead over a time window. All 12 leads are recorded simultaneously,
// so the four columns are consecutive slices of the same run — phase is
// continuous left-to-right (globalTime), which is how printed ECGs read.
function leadTrace(prof, globalStart, durationSec, baseline) {
  const pts = [];
  const n = Math.round(durationSec / DT);
  for (let i = 0; i <= n; i++) {
    const lt = i * DT;
    const gt = globalStart + lt;
    pts.push([gt, baseline + ecgBeat(gt % RR, prof)]);
  }
  return pts;
}

// 1 mV / 0.2 s rectangular calibration pulse drawn in the left margin of a row.
function calPulse(baseline) {
  return [
    [-0.55, baseline],
    [-0.40, baseline],
    [-0.40, baseline + 1],
    [-0.20, baseline + 1],
    [-0.20, baseline],
    [-0.05, baseline],
  ];
}

// --- Build series -----------------------------------------------------------
const lineDefaults = {
  type: "line",
  showSymbol: false,
  smooth: false,
  clip: true,
  silent: true,
  z: 5,
  lineStyle: { color: TRACE, width: 1.8, join: "round" },
};

const series = [];
LAYOUT.forEach((rowLeads, r) => {
  rowLeads.forEach((name, c) => {
    series.push({
      ...lineDefaults,
      data: leadTrace(PROFILES[name], c * COL_SEC, COL_SEC, ROW_BASELINE[r]),
    });
  });
  series.push({ ...lineDefaults, data: calPulse(ROW_BASELINE[r]) });
});
// Full-length Lead II rhythm strip across the bottom (all four columns wide).
series.push({
  ...lineDefaults,
  data: leadTrace(PROFILES["II"], 0, COL_SEC * 4, RHYTHM_BASELINE),
});
series.push({ ...lineDefaults, data: calPulse(RHYTHM_BASELINE) });

// --- Coordinate window + pixel mapping for lead labels ----------------------
const GL = 64, GR = 36, GT = 122, GB = 75; // grid margins (CSS px)
const X0 = -0.6, X1 = 10.0; // seconds (4 columns x 2.5 s, plus cal margin)
const Y0 = -1.5, Y1 = 11.0; // mV
const gW = W - GL - GR;
const gH = H - GT - GB;
const px = (x) => GL + ((x - X0) / (X1 - X0)) * gW;
const py = (y) => GT + ((Y1 - y) / (Y1 - Y0)) * gH;

const graphic = [];
LAYOUT.forEach((rowLeads, r) => {
  rowLeads.forEach((name, c) => {
    graphic.push({
      type: "text",
      left: px(c * COL_SEC + 0.05),
      top: py(ROW_BASELINE[r] + 1.45),
      style: { text: name, fill: t.ink, font: "bold 17px sans-serif" },
    });
  });
});
graphic.push({
  type: "text",
  left: px(0.05),
  top: py(RHYTHM_BASELINE + 1.35),
  style: { text: "II", fill: t.ink, font: "bold 17px sans-serif" },
});
// Standard paper-scale annotation, bottom-left.
graphic.push({
  type: "text",
  left: GL,
  top: H - 26,
  style: {
    text: "25 mm/s     10 mm/mV     1 mV cal",
    fill: t.inkSoft,
    font: "13px sans-serif",
  },
});

// --- Axis (ECG paper ruling) ------------------------------------------------
const ecgAxis = (min, max, interval) => ({
  type: "value",
  min,
  max,
  interval,
  axisLabel: { show: false },
  axisLine: { show: false },
  axisTick: { show: false },
  splitLine: { show: true, lineStyle: { color: MAJOR_GRID, width: 1 } },
  minorTick: { show: false, splitNumber: 5 },
  minorSplitLine: { show: true, lineStyle: { color: MINOR_GRID, width: 0.6 } },
});

// --- Init + render ----------------------------------------------------------
const chart = echarts.init(document.getElementById("container"));
chart.setOption({
  animation: false,
  backgroundColor: t.pageBg,
  title: {
    text: "ecg-twelve-lead · javascript · echarts · anyplot.ai",
    left: "center",
    top: 22,
    textStyle: { color: t.ink, fontSize: 22, fontWeight: 600 },
  },
  grid: { left: GL, right: GR, top: GT, bottom: GB, containLabel: false },
  xAxis: ecgAxis(X0, X1, 0.2),
  yAxis: ecgAxis(Y0, Y1, 0.5),
  graphic,
  series,
});

chart.on("finished", () => {
  window.__anyplotReady = true;
});

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

Other implementations