{"spec_id":"ecg-twelve-lead","library":"altair","language":"python","code":"\"\"\" anyplot.ai\necg-twelve-lead: ECG/EKG 12-Lead Waveform Display\nLibrary: altair 6.2.1 | Python 3.13.13\nQuality: 94/100 | Updated: 2026-06-17\n\"\"\"\n\nimport os\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n\n# Theme tokens (see prompts/default-style-guide.md \"Theme-adaptive Chrome\")\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nBRAND = \"#009E73\"  # Imprint palette position 1 — the ECG trace (single data series)\n\n# Theme-adaptive ECG paper: pink printout on light, dark red-tinted paper on dark\nECG_PAPER = \"#FFF0EC\" if THEME == \"light\" else \"#2B1A18\"\nGRID_FINE = \"#E8B4B4\" if THEME == \"light\" else \"#4A2A28\"\nGRID_BOLD = \"#C87872\" if THEME == \"light\" else \"#6E3C38\"\nPAPER_EDGE = \"#D4908A\" if THEME == \"light\" else \"#5A3A36\"\nANNOT = \"#AE3030\" if THEME == \"light\" else \"#E68B82\"  # Imprint matte red — wave annotations\n\n# Data — Synthetic ECG using Gaussian-based waveform model\nnp.random.seed(42)\nfs = 1000\nduration = 2.5\nt = np.linspace(0, duration, int(fs * duration))\nheart_rate = 72\nbeat_interval = 60.0 / heart_rate\nbeat_t = np.linspace(0, beat_interval, int(fs * beat_interval))\n\n# P wave, QRS complex (Q dip, R peak, S dip), T wave — each as Gaussian pulse\np_wave = 0.15 * np.exp(-((beat_t - 0.16) ** 2) / (2 * 0.025**2))\nq_wave = -0.12 * np.exp(-((beat_t - 0.24) ** 2) / (2 * 0.008**2))\nr_wave = 1.0 * np.exp(-((beat_t - 0.26) ** 2) / (2 * 0.012**2))\ns_wave = -0.2 * np.exp(-((beat_t - 0.28) ** 2) / (2 * 0.010**2))\nt_wave = 0.3 * np.exp(-((beat_t - 0.42) ** 2) / (2 * 0.040**2))\nsingle_beat = p_wave + q_wave + r_wave + s_wave + t_wave\n\n# Tile beats across full duration\nn_beats = int(np.ceil(duration / beat_interval)) + 1\nfull_template = np.tile(single_beat, n_beats)[: len(t)]\n\n# Lead-specific amplitude/polarity factors and precordial R-wave progression\nlead_factors = {\n    \"I\": 0.8,\n    \"II\": 1.0,\n    \"III\": 0.5,\n    \"aVR\": -0.7,\n    \"aVL\": 0.3,\n    \"aVF\": 0.75,\n    \"V1\": -0.4,\n    \"V2\": 0.1,\n    \"V3\": 0.6,\n    \"V4\": 1.0,\n    \"V5\": 0.9,\n    \"V6\": 0.7,\n}\nprecordial_r = {\"V1\": 0.3, \"V2\": 0.5, \"V3\": 0.8, \"V4\": 1.2, \"V5\": 1.1, \"V6\": 0.9}\nprecordial_s = {\"V1\": 1.5, \"V2\": 1.2, \"V3\": 0.8, \"V4\": 0.3, \"V5\": 0.2, \"V6\": 0.1}\n\nlead_signals = {}\nfor lead_name, factor in lead_factors.items():\n    signal = full_template * factor\n    if lead_name in precordial_r:\n        r_mod = (precordial_r[lead_name] - 1.0) * np.exp(-((beat_t - 0.26) ** 2) / (2 * 0.012**2))\n        s_mod = -(precordial_s[lead_name] - 1.0) * 0.2 * np.exp(-((beat_t - 0.28) ** 2) / (2 * 0.010**2))\n        signal = signal + np.tile(r_mod + s_mod, n_beats)[: len(t)]\n    lead_signals[lead_name] = signal + np.random.normal(0, 0.008, len(t))\n\n# Standard clinical 3x4 grid layout\ngrid_layout = [[\"I\", \"aVR\", \"V1\", \"V4\"], [\"II\", \"aVL\", \"V2\", \"V5\"], [\"III\", \"aVF\", \"V3\", \"V6\"]]\n\n# Build combined dataframe for all 12 leads with row/col position\nall_leads = []\nfor row_idx, row_leads in enumerate(grid_layout):\n    for col_idx, lead_name in enumerate(row_leads):\n        df = pd.DataFrame({\"time\": t, \"voltage\": lead_signals[lead_name]})\n        df[\"lead\"] = lead_name\n        df[\"row\"] = row_idx\n        df[\"col\"] = col_idx\n        all_leads.append(df)\nleads_df = pd.concat(all_leads, ignore_index=True)\n\n# Chart dimensions — kept small so vl-convert padding still fits 3200x1800\npanel_w = 181\npanel_h = 75\nrhythm_h = 60\ncol_spacing = 6\nrow_spacing = 6\nx_domain = [0, duration]\ny_domain = [-1.2, 1.5]\n\n# ECG paper grid line data (fine at ~1mm, bold at ~5mm)\nfine_h_lines = pd.DataFrame({\"y\": np.arange(-1.5, 1.61, 0.1)})\nbold_h_lines = pd.DataFrame({\"y\": np.arange(-1.5, 1.61, 0.5)})\nfine_v_lines = pd.DataFrame({\"x\": np.arange(0, duration + 0.01, 0.04)})\nbold_v_lines = pd.DataFrame({\"x\": np.arange(0, duration + 0.01, 0.2)})\n\n# Reusable grid layers — created once, used in all panels\ngrid_layers = (\n    alt.Chart(fine_h_lines)\n    .mark_rule(color=GRID_FINE, strokeWidth=0.5, opacity=0.6)\n    .encode(y=alt.Y(\"y:Q\", scale=alt.Scale(domain=y_domain), axis=None))\n    + alt.Chart(bold_h_lines)\n    .mark_rule(color=GRID_BOLD, strokeWidth=1.2, opacity=0.7)\n    .encode(y=alt.Y(\"y:Q\", scale=alt.Scale(domain=y_domain), axis=None))\n    + alt.Chart(fine_v_lines)\n    .mark_rule(color=GRID_FINE, strokeWidth=0.5, opacity=0.6)\n    .encode(x=alt.X(\"x:Q\", scale=alt.Scale(domain=x_domain), axis=None))\n    + alt.Chart(bold_v_lines)\n    .mark_rule(color=GRID_BOLD, strokeWidth=1.2, opacity=0.7)\n    .encode(x=alt.X(\"x:Q\", scale=alt.Scale(domain=x_domain), axis=None))\n)\n\n# Plot — Build 3x4 lead grid using layered hconcat/vconcat composition\nrows = []\nfor row_idx, row_leads in enumerate(grid_layout):\n    show_x = row_idx == 2\n    lead_charts = []\n    for col_idx, lead_name in enumerate(row_leads):\n        lead_df = leads_df[(leads_df[\"row\"] == row_idx) & (leads_df[\"col\"] == col_idx)]\n\n        x_enc = (\n            alt.X(\n                \"time:Q\",\n                scale=alt.Scale(domain=x_domain),\n                axis=alt.Axis(title=\"Time (s)\", titleFontSize=12, labelFontSize=10, tickCount=6),\n            )\n            if show_x\n            else alt.X(\"time:Q\", scale=alt.Scale(domain=x_domain), axis=None)\n        )\n\n        signal_layer = (\n            alt.Chart(lead_df)\n            .mark_line(strokeWidth=1.4, interpolate=\"monotone\", color=BRAND)\n            .encode(x=x_enc, y=alt.Y(\"voltage:Q\", scale=alt.Scale(domain=y_domain), axis=None))\n        )\n\n        label_df = pd.DataFrame({\"x\": [0.06], \"y\": [1.35], \"text\": [lead_name]})\n        label_layer = (\n            alt.Chart(label_df)\n            .mark_text(fontSize=12, fontWeight=\"bold\", align=\"left\", baseline=\"top\", color=INK)\n            .encode(\n                x=alt.X(\"x:Q\", scale=alt.Scale(domain=x_domain)),\n                y=alt.Y(\"y:Q\", scale=alt.Scale(domain=y_domain)),\n                text=\"text:N\",\n            )\n        )\n\n        panel = (grid_layers + signal_layer + label_layer).properties(width=panel_w, height=panel_h)\n        lead_charts.append(panel)\n    rows.append(alt.hconcat(*lead_charts, spacing=col_spacing))\n\n# Rhythm strip — full-length Lead II across bottom\nrhythm_df = pd.DataFrame({\"time\": t, \"voltage\": lead_signals[\"II\"]})\nrhythm_signal = (\n    alt.Chart(rhythm_df)\n    .mark_line(strokeWidth=1.6, interpolate=\"monotone\", color=BRAND)\n    .encode(\n        x=alt.X(\n            \"time:Q\",\n            scale=alt.Scale(domain=x_domain),\n            axis=alt.Axis(title=\"Time (s)\", titleFontSize=12, labelFontSize=10, tickCount=10),\n        ),\n        y=alt.Y(\"voltage:Q\", scale=alt.Scale(domain=y_domain), axis=None),\n    )\n)\n\nrhythm_label_df = pd.DataFrame({\"x\": [0.12], \"y\": [1.35], \"text\": [\"II (rhythm)\"]})\nrhythm_label = (\n    alt.Chart(rhythm_label_df)\n    .mark_text(fontSize=12, fontWeight=\"bold\", align=\"left\", baseline=\"top\", color=INK)\n    .encode(\n        x=alt.X(\"x:Q\", scale=alt.Scale(domain=x_domain)),\n        y=alt.Y(\"y:Q\", scale=alt.Scale(domain=y_domain)),\n        text=\"text:N\",\n    )\n)\n\n# Calibration pulse (1mV square at start of rhythm strip)\ncal_df = pd.DataFrame({\"time\": [0.0, 0.0, 0.04, 0.04, 0.08, 0.08], \"voltage\": [0.0, 1.0, 1.0, 0.0, 0.0, 0.0]})\ncal_signal = (\n    alt.Chart(cal_df)\n    .mark_line(strokeWidth=1.6, color=INK)\n    .encode(x=alt.X(\"time:Q\", scale=alt.Scale(domain=x_domain)), y=alt.Y(\"voltage:Q\", scale=alt.Scale(domain=y_domain)))\n)\ncal_label_df = pd.DataFrame({\"x\": [0.04], \"y\": [1.12], \"text\": [\"1 mV\"]})\ncal_label = (\n    alt.Chart(cal_label_df)\n    .mark_text(fontSize=10, fontWeight=\"bold\", align=\"center\", baseline=\"bottom\", color=INK_SOFT)\n    .encode(\n        x=alt.X(\"x:Q\", scale=alt.Scale(domain=x_domain)),\n        y=alt.Y(\"y:Q\", scale=alt.Scale(domain=y_domain)),\n        text=\"text:N\",\n    )\n)\n\n# Waveform annotation on rhythm strip — label P, QRS, T morphology\nannot_data = pd.DataFrame({\"x\": [0.16, 0.26, 0.42], \"y\": [0.35, 1.25, 0.50], \"text\": [\"P\", \"R\", \"T\"]})\nannot_layer = (\n    alt.Chart(annot_data)\n    .mark_text(fontSize=11, fontWeight=\"bold\", fontStyle=\"italic\", align=\"center\", dy=-8, color=ANNOT)\n    .encode(\n        x=alt.X(\"x:Q\", scale=alt.Scale(domain=x_domain)),\n        y=alt.Y(\"y:Q\", scale=alt.Scale(domain=y_domain)),\n        text=\"text:N\",\n    )\n)\n\nrhythm_strip = (grid_layers + rhythm_signal + rhythm_label + cal_signal + cal_label + annot_layer).properties(\n    width=panel_w * 4 + col_spacing * 3, height=rhythm_h\n)\n\n# Style — Combine all rows and rhythm strip\nchart = (\n    alt.vconcat(*rows, rhythm_strip, spacing=row_spacing)\n    .properties(\n        title=alt.Title(\n            \"ecg-twelve-lead · python · altair · anyplot.ai\",\n            fontSize=18,\n            fontWeight=\"bold\",\n            color=INK,\n            anchor=\"middle\",\n            subtitle=[\"Normal Sinus Rhythm · 72 BPM · 12-Lead ECG\", \"25 mm/s · 10 mm/mV\"],\n            subtitleFontSize=12,\n            subtitleColor=INK_SOFT,\n            offset=8,\n        )\n    )\n    .configure_view(strokeWidth=0.6, stroke=PAPER_EDGE, fill=ECG_PAPER, cornerRadius=2)\n    .configure_concat(spacing=row_spacing)\n    .configure(background=PAGE_BG, padding={\"left\": 12, \"right\": 12, \"top\": 8, \"bottom\": 8})\n)\n\n# Save — render then pad to the exact 3200x1800 landscape target (no crop)\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\nchart.save(f\"plot-{THEME}.html\")\n\nTW, TH = 3200, 1800\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. Shrink panel/title sizes and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n"}