{"spec_id":"ecg-twelve-lead","library":"d3","language":"javascript","code":"// anyplot.ai\n// ecg-twelve-lead: ECG/EKG 12-Lead Waveform Display\n// Library: d3 7.9.0 | JavaScript 22.22.3\n// Quality: 91/100 | Created: 2026-06-17\n//# anyplot-orientation: landscape\n// anyplot.ai\n// ecg-twelve-lead: ECG/EKG 12-Lead Waveform Display\n// Library: d3 7.9.0 | JavaScript 22\n// Quality: pending | Created: 2026-06-17\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- ECG paper geometry ------------------------------------------------------\n// Standard clinical scale: 25 mm/s horizontal, 10 mm/mV vertical. One small grid\n// square = 1 mm, one bold square = 5 mm. pxPerMm fixes the physical scale; every\n// downstream measurement (strip width, voltage, calibration pulse) derives from it.\nconst pxPerMm = 5.8;\nconst pxPerMv = 10 * pxPerMm; // 10 mm/mV\nconst mmPerSec = 25; // paper speed\nconst pxPerSec = mmPerSec * pxPerMm; // 145 px/s\nconst stripSec = 2.5; // seconds shown per lead cell (2.5 s × 4 cols = 10 s)\nconst calGutterMm = 15; // left margin reserved for the 1 mV calibration pulse\n\n// Continuous pink grid spans the whole plot rectangle, like real ECG paper.\nconst gridTop = 64;\nconst gridWmm = calGutterMm + 4 * stripSec * mmPerSec; // 15 + 250 = 265 mm\nconst gridWpx = gridWmm * pxPerMm;\nconst gridLeft = Math.round((width - gridWpx) / 2);\nconst gridHmm = Math.floor((height - gridTop - 26) / pxPerMm);\nconst gridHpx = gridHmm * pxPerMm;\nconst gridRight = gridLeft + gridWpx;\nconst gridBottom = gridTop + gridHpx;\n\n// Vertical split: three rows of leads + one full-width Lead II rhythm strip.\nconst rhythmH = 150;\nconst leadH = (gridHpx - rhythmH) / 3;\nconst colWpx = stripSec * pxPerSec; // 362.5 px per 2.5 s cell\nconst colX0 = (c) => gridLeft + calGutterMm * pxPerMm + c * colWpx;\nconst rowBaseline = (r) => gridTop + leadH * (r + 0.5);\nconst rhythmBaseline = gridTop + 3 * leadH + rhythmH / 2;\n\n// Standard clinical 3×4 placement (columns I/aVR/V1/V4 · II/aVL/V2/V5 · III/aVF/V3/V6).\nconst layout = [\n  [\"I\", \"aVR\", \"V1\", \"V4\"],\n  [\"II\", \"aVL\", \"V2\", \"V5\"],\n  [\"III\", \"aVF\", \"V3\", \"V6\"],\n];\n\n// --- Synthetic normal sinus rhythm ------------------------------------------\n// Each beat is a sum of Gaussian P-Q-R-S-T deflections (a compact analogue of the\n// McSharry ECG model). Per-lead coefficients (mV) set the signed amplitude of each\n// wave, reproducing the textbook morphology: inverted aVR, rS in V1 progressing to\n// a tall R in V5/V6, and the limb-lead pattern. Amplitudes sit in normal ranges\n// (P ~0.1–0.25 mV, QRS ~0.5–1.5 mV, T ~0.1–0.5 mV).\nconst beatSec = 0.8; // ~75 bpm\nconst waves = [\n  { key: \"P\", mu: 0.13, sig: 0.02 },\n  { key: \"Q\", mu: 0.205, sig: 0.008 },\n  { key: \"R\", mu: 0.235, sig: 0.01 },\n  { key: \"S\", mu: 0.265, sig: 0.011 },\n  { key: \"T\", mu: 0.42, sig: 0.046 },\n];\nconst coef = {\n  I: { P: 0.1, Q: -0.06, R: 0.9, S: -0.12, T: 0.22 },\n  II: { P: 0.15, Q: -0.05, R: 1.25, S: -0.15, T: 0.33 },\n  III: { P: 0.07, Q: -0.03, R: 0.62, S: -0.18, T: 0.13 },\n  aVR: { P: -0.11, Q: 0, R: -0.85, S: 0, T: -0.24 },\n  aVL: { P: 0.06, Q: -0.04, R: 0.52, S: -0.1, T: 0.12 },\n  aVF: { P: 0.12, Q: -0.04, R: 0.92, S: -0.14, T: 0.2 },\n  V1: { P: 0.05, Q: 0, R: 0.28, S: -0.95, T: -0.06 },\n  V2: { P: 0.06, Q: 0, R: 0.55, S: -1.15, T: 0.3 },\n  V3: { P: 0.07, Q: 0, R: 0.85, S: -0.75, T: 0.42 },\n  V4: { P: 0.08, Q: -0.03, R: 1.4, S: -0.4, T: 0.45 },\n  V5: { P: 0.09, Q: -0.04, R: 1.45, S: -0.22, T: 0.4 },\n  V6: { P: 0.1, Q: -0.04, R: 1.15, S: -0.12, T: 0.3 },\n};\n\nconst ecg = (time, lead) => {\n  const tau = ((time % beatSec) + beatSec) % beatSec;\n  let v = 0;\n  for (const w of waves) {\n    const a = coef[lead][w.key];\n    if (a) v += a * Math.exp(-((tau - w.mu) ** 2) / (2 * w.sig * w.sig));\n  }\n  return v;\n};\n\n// 1000 Hz sampling (2500 samples / 2.5 s strip), matching the spec acquisition rate.\nconst dt = 0.001;\nconst sample = (lead, dur) => {\n  const pts = [];\n  for (let time = 0; time <= dur + 1e-9; time += dt) pts.push({ t: time, v: ecg(time, lead) });\n  return pts;\n};\n\n// d3.scaleLinear carries the physical ECG calibration in measured units: seconds→px\n// at 25 mm/s and mV→px at 10 mm/mV. Each cell reuses the same scales, offset by its\n// own (x0, yb) origin, so the d3.line accessors read in clinical units, not pixels.\nconst xScale = d3.scaleLinear().domain([0, 1]).range([0, pxPerSec]);\nconst vScale = d3.scaleLinear().domain([0, 1]).range([0, pxPerMv]);\nconst traceLine = (x0, yb) =>\n  d3\n    .line()\n    .x((d) => x0 + xScale(d.t))\n    .y((d) => yb - vScale(d.v));\n\n// --- SVG mount ---------------------------------------------------------------\nconst svg = d3\n  .select(\"#container\")\n  .append(\"svg\")\n  .attr(\"width\", width)\n  .attr(\"height\", height);\n\n// --- ECG paper grid (Imprint matte-red #AE3030, the palette's red anchor) -----\nconst major = (l) => l % 5 === 0;\nlet minorD = \"\";\nlet majorD = \"\";\nconst nCols = Math.floor(gridWpx / pxPerMm);\nconst nRows = Math.floor(gridHpx / pxPerMm);\nfor (let mm = 0; mm <= nCols; mm++) {\n  const x = gridLeft + mm * pxPerMm;\n  const seg = `M${x},${gridTop}V${gridBottom}`;\n  if (major(mm)) majorD += seg;\n  else minorD += seg;\n}\nfor (let mm = 0; mm <= nRows; mm++) {\n  const y = gridTop + mm * pxPerMm;\n  const seg = `M${gridLeft},${y}H${gridRight}`;\n  if (major(mm)) majorD += seg;\n  else minorD += seg;\n}\nconst minorOpacity = t.theme === \"light\" ? 0.16 : 0.22;\nconst majorOpacity = t.theme === \"light\" ? 0.34 : 0.46;\nsvg\n  .append(\"path\")\n  .attr(\"d\", minorD)\n  .attr(\"stroke\", \"#AE3030\")\n  .attr(\"stroke-width\", 0.5)\n  .attr(\"stroke-opacity\", minorOpacity)\n  .attr(\"fill\", \"none\");\nsvg\n  .append(\"path\")\n  .attr(\"d\", majorD)\n  .attr(\"stroke\", \"#AE3030\")\n  .attr(\"stroke-width\", 1.1)\n  .attr(\"stroke-opacity\", majorOpacity)\n  .attr(\"fill\", \"none\");\n\n// --- Calibration pulse + waveform trace --------------------------------------\n// Each row opens with a 1 mV (10 mm) step pulse in the left gutter, the standard\n// reference mark. Trace and pulse share the brand-green ink (Imprint position 1).\nconst BRAND = t.palette[0];\nconst calAmp = 1.0 * pxPerMv; // 1 mV\nconst calPulse = (yb) => {\n  const x = gridLeft + 8;\n  const w = 5 * pxPerMm; // 5 mm flat top\n  return `M${x},${yb}H${x + 14}V${yb - calAmp}H${x + 14 + w}V${yb}H${x + 14 + w + 14}`;\n};\nconst drawCal = (yb) =>\n  svg\n    .append(\"path\")\n    .attr(\"d\", calPulse(yb))\n    .attr(\"stroke\", BRAND)\n    .attr(\"stroke-width\", 2)\n    .attr(\"fill\", \"none\")\n    .attr(\"stroke-linejoin\", \"round\");\nconst drawTrace = (pts, x0, yb) =>\n  svg\n    .append(\"path\")\n    .attr(\"d\", traceLine(x0, yb)(pts))\n    .attr(\"stroke\", BRAND)\n    .attr(\"stroke-width\", 1.9)\n    .attr(\"fill\", \"none\")\n    .attr(\"stroke-linejoin\", \"round\")\n    .attr(\"stroke-linecap\", \"round\");\n\n// 12 lead cells\nlayout.forEach((row, r) => {\n  const yb = rowBaseline(r);\n  drawCal(yb);\n  row.forEach((lead, c) => {\n    drawTrace(sample(lead, stripSec), colX0(c), yb);\n    svg\n      .append(\"text\")\n      .attr(\"x\", colX0(c) + 8)\n      .attr(\"y\", yb - leadH / 2 + 24)\n      .attr(\"fill\", t.ink)\n      .style(\"font-size\", \"22px\")\n      .style(\"font-weight\", \"700\")\n      .text(lead);\n  });\n});\n\n// Full-length Lead II rhythm strip across the bottom (10 s continuous)\ndrawCal(rhythmBaseline);\ndrawTrace(sample(\"II\", 4 * stripSec), colX0(0), rhythmBaseline);\nsvg\n  .append(\"text\")\n  .attr(\"x\", colX0(0) + 8)\n  .attr(\"y\", rhythmBaseline - rhythmH / 2 + 24)\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"700\")\n  .text(\"II\");\n\n// --- Title + scale footnote --------------------------------------------------\nconst title = \"Normal Sinus Rhythm · ecg-twelve-lead · javascript · d3 · anyplot.ai\";\nconst titleSize = title.length > 67 ? Math.max(14, Math.round((22 * 67) / title.length)) : 22;\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 40)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", `${titleSize}px`)\n  .style(\"font-weight\", \"600\")\n  .text(title);\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", gridRight)\n  .attr(\"y\", height - 8)\n  .attr(\"text-anchor\", \"end\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"15px\")\n  .text(\"25 mm/s · 10 mm/mV · 1 mV calibration\");\n"}