{"spec_id":"swimmer-clinical-timeline","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nswimmer-clinical-timeline: Swimmer Plot for Clinical Trial Timelines\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 84/100 | Updated: 2026-06-08\n\"\"\"\n\nimport os\nimport re\nimport sys\nimport xml.etree.ElementTree as ET\n\n\n# Remove this file's directory from sys.path to prevent importing itself\n# instead of the installed pygal package (file and package share the same name).\n_here = os.path.abspath(os.path.dirname(__file__) or \".\")\nsys.path = [p for p in sys.path if os.path.abspath(p or \".\") != _here]\n\nimport cairosvg\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\n\n\n# Theme tokens — Imprint palette, 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\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\nIMPRINT_PALETTE = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\")\n\n# Treatment arm colors: Imprint positions 1 (green) and 2 (lavender)\nARM_COLORS = {\"Arm A (Combo)\": \"#009E73\", \"Arm B (Mono)\": \"#C475FD\"}\n\n# Event marker colors: Imprint positions 3–6, visually distinct from both arm colors\nEVENT_CONFIG = {\n    \"partial_response\": {\"color\": \"#4467A3\", \"label\": \"Partial Response\"},\n    \"complete_response\": {\"color\": \"#BD8233\", \"label\": \"Complete Response\"},\n    \"progressive_disease\": {\"color\": \"#AE3030\", \"label\": \"Progressive Disease\"},\n    \"adverse_event\": {\"color\": \"#2ABCCD\", \"label\": \"Adverse Event\"},\n}\n\n# Data — simulated Phase II oncology trial, 25 patients across two treatment arms\nnp.random.seed(42)\npatient_ids = [f\"PT-{i:03d}\" for i in range(1, 26)]\narms = [\"Arm A (Combo)\"] * 13 + [\"Arm B (Mono)\"] * 12\n\ndurations_a = np.random.exponential(scale=28, size=13) + 6\ndurations_b = np.random.exponential(scale=18, size=12) + 4\ndurations = np.clip(np.concatenate([durations_a, durations_b]), 4, 60).round(1)\n\nevents = []\nfor i in range(25):\n    dur = durations[i]\n    pat_events = []\n    if np.random.random() < 0.75:\n        pr_time = np.random.uniform(4, min(12, dur - 1))\n        pat_events.append((\"partial_response\", round(pr_time, 1)))\n        if np.random.random() < 0.35 and dur > pr_time + 6:\n            cr_time = pr_time + np.random.uniform(6, min(16, dur - pr_time - 1))\n            pat_events.append((\"complete_response\", round(cr_time, 1)))\n    if np.random.random() < 0.4:\n        pd_time = np.random.uniform(max(8, dur * 0.5), dur)\n        pat_events.append((\"progressive_disease\", round(pd_time, 1)))\n    if np.random.random() < 0.3:\n        ae_time = np.random.uniform(2, min(dur - 1, 20))\n        pat_events.append((\"adverse_event\", round(ae_time, 1)))\n    events.append(pat_events)\n\nongoing = [\n    not any(e[0] == \"progressive_disease\" for e in events[i]) and durations[i] > 30 and np.random.random() < 0.6\n    for i in range(25)\n]\n\n# Sort by duration, longest first (creates clear visual hierarchy)\nsort_idx = np.argsort(-durations)\npatient_ids = [patient_ids[i] for i in sort_idx]\narms = [arms[i] for i in sort_idx]\ndurations = durations[sort_idx]\nevents = [events[i] for i in sort_idx]\nongoing = [ongoing[i] for i in sort_idx]\nnum_patients = len(patient_ids)\nmax_duration = float(np.ceil(max(durations) / 10) * 10)\n\n# pygal HorizontalBar renders bottom-to-top; reverse for longest-at-top display\nrev_ids = list(reversed(patient_ids))\nrev_arms = list(reversed(arms))\nrev_durs = list(reversed(durations))\nrev_events = list(reversed(events))\nrev_ongoing = list(reversed(ongoing))\n\n# Plot — canvas 3200×1800 (landscape, hard rule)\ntitle = \"swimmer-clinical-timeline · python · pygal · anyplot.ai\"\n\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=IMPRINT_PALETTE,\n    title_font_size=66,\n    label_font_size=50,\n    major_label_font_size=44,\n    legend_font_size=40,\n    value_font_size=30,\n    stroke_width=2.5,\n)\n\nchart = pygal.HorizontalBar(\n    width=3200,\n    height=1800,\n    style=custom_style,\n    title=title,\n    x_title=\"Time on Treatment (Weeks)\",\n    show_legend=False,\n    print_values=False,\n    show_y_guides=True,\n    show_x_guides=True,\n    margin=80,\n    margin_bottom=230,\n    spacing=4,\n    range=(0, max_duration),\n    rounded_bars=4,\n    y_labels_major_every=1,\n    truncate_label=8,\n)\n\nchart.x_labels = rev_ids\n\nchart.add(\n    \"Duration\",\n    [\n        {\n            \"value\": float(rev_durs[i]),\n            \"color\": ARM_COLORS[rev_arms[i]],\n            \"label\": f\"{rev_ids[i]}: {rev_durs[i]:.1f} wk ({rev_arms[i]})\",\n        }\n        for i in range(num_patients)\n    ],\n)\n\n# Render SVG and extract bar positions for event marker injection\nsvg_str = chart.render().decode(\"utf-8\")\nroot = ET.fromstring(svg_str)\n\n# Find plot group translation offset\ntx, ty = 0.0, 0.0\nfor g in root.iter(\"{http://www.w3.org/2000/svg}g\"):\n    if g.get(\"class\", \"\") == \"plot\":\n        m = re.search(r\"translate\\(([^,]+),\\s*([^)]+)\\)\", g.get(\"transform\", \"\"))\n        if m:\n            tx, ty = float(m.group(1)), float(m.group(2))\n        break\n\n# Extract bar rects (translated to global SVG coordinates)\nbar_rects = sorted(\n    [\n        {\n            \"x\": float(r.get(\"x\", 0)) + tx,\n            \"y\": float(r.get(\"y\", 0)) + ty,\n            \"width\": float(r.get(\"width\", 0)),\n            \"height\": float(r.get(\"height\", 0)),\n        }\n        for r in root.iter(\"{http://www.w3.org/2000/svg}rect\")\n        if \"rect reactive tooltip-trigger\" in r.get(\"class\", \"\")\n    ],\n    key=lambda b: b[\"y\"],\n    reverse=True,\n)\n\n# Build SVG marker elements for events and ongoing arrows\nSTROKE = PAGE_BG  # outline matches page bg for clean contrast on both themes\nmarker_svgs = []\nms = 18  # marker half-size in SVG user units\n\nif len(bar_rects) == num_patients:\n    for i, bar in enumerate(bar_rects):\n        bx, bw = bar[\"x\"], bar[\"width\"]\n        cy = bar[\"y\"] + bar[\"height\"] / 2\n        dur = rev_durs[i]\n\n        if rev_ongoing[i]:\n            ax = bx + bw\n            marker_svgs.append(\n                f'<polygon points=\"{ax:.1f},{cy - ms:.1f} '\n                f'{ax + ms * 2:.1f},{cy:.1f} {ax:.1f},{cy + ms:.1f}\" '\n                f'fill=\"{ARM_COLORS[rev_arms[i]]}\" opacity=\"0.9\"/>'\n            )\n\n        for etype, etime in rev_events[i]:\n            col = EVENT_CONFIG[etype][\"color\"]\n            ex = bx + (etime / dur) * bw\n\n            if etype == \"partial_response\":\n                marker_svgs.append(\n                    f'<polygon points=\"{ex:.1f},{cy - ms:.1f} '\n                    f'{ex - ms:.1f},{cy + ms:.1f} {ex + ms:.1f},{cy + ms:.1f}\" '\n                    f'fill=\"{col}\" stroke=\"{STROKE}\" stroke-width=\"2\"/>'\n                )\n            elif etype == \"complete_response\":\n                pts = \" \".join(\n                    f\"{ex + (ms if j % 2 == 0 else ms * 0.42) * np.cos(-np.pi / 2 + j * np.pi / 5):.1f},\"\n                    f\"{cy + (ms if j % 2 == 0 else ms * 0.42) * np.sin(-np.pi / 2 + j * np.pi / 5):.1f}\"\n                    for j in range(10)\n                )\n                marker_svgs.append(f'<polygon points=\"{pts}\" fill=\"{col}\" stroke=\"{STROKE}\" stroke-width=\"2\"/>')\n            elif etype == \"progressive_disease\":\n                marker_svgs.append(\n                    f'<polygon points=\"{ex:.1f},{cy - ms:.1f} {ex + ms:.1f},{cy:.1f} '\n                    f'{ex:.1f},{cy + ms:.1f} {ex - ms:.1f},{cy:.1f}\" '\n                    f'fill=\"{col}\" stroke=\"{STROKE}\" stroke-width=\"2\"/>'\n                )\n            elif etype == \"adverse_event\":\n                h = ms * 0.8\n                marker_svgs.append(\n                    f'<rect x=\"{ex - h:.1f}\" y=\"{cy - h:.1f}\" '\n                    f'width=\"{h * 2:.1f}\" height=\"{h * 2:.1f}\" '\n                    f'fill=\"{col}\" stroke=\"{STROKE}\" stroke-width=\"2\" rx=\"3\"/>'\n                )\n\n# Two-row legend: row 1 = treatment arms, row 2 = event types\nleg_y1, leg_y2 = 1725, 1778\nfont_lg = 44\n\narm_legend_x = [1000, 1750]\nfor idx, (arm_name, arm_col) in enumerate(ARM_COLORS.items()):\n    x = arm_legend_x[idx]\n    marker_svgs += [\n        f'<rect x=\"{x}\" y=\"{leg_y1 - 12}\" width=\"26\" height=\"18\" fill=\"{arm_col}\" rx=\"3\"/>',\n        f'<text x=\"{x + 36}\" y=\"{leg_y1 + 3}\" font-size=\"{font_lg}\" fill=\"{INK_SOFT}\">{arm_name}</text>',\n    ]\n\nevent_legend = [\n    (\"▲\", \"#4467A3\", \"Partial Response\"),\n    (\"★\", \"#BD8233\", \"Complete Response\"),\n    (\"◆\", \"#AE3030\", \"Progressive Disease\"),\n    (\"■\", \"#2ABCCD\", \"Adverse Event\"),\n    (\"▶\", INK_SOFT, \"Ongoing\"),\n]\nevt_legend_x = [230, 790, 1360, 1920, 2470]\nfor idx, (sym, col, lbl) in enumerate(event_legend):\n    x = evt_legend_x[idx]\n    marker_svgs += [\n        f'<text x=\"{x}\" y=\"{leg_y2 + 2}\" font-size=\"42\" fill=\"{col}\" text-anchor=\"middle\">{sym}</text>',\n        f'<text x=\"{x + 26}\" y=\"{leg_y2 + 2}\" font-size=\"{font_lg}\" fill=\"{INK_SOFT}\">{lbl}</text>',\n    ]\n\nsvg_output = svg_str.replace(\"</svg>\", \"\\n\".join(marker_svgs) + \"\\n</svg>\")\nsvg_output = svg_output.replace(\">No data<\", \"><\")\n\n# Save\nwith open(f\"plot-{THEME}.html\", \"w\") as f:\n    f.write(svg_output)\n\ncairosvg.svg2png(bytestring=svg_output.encode(), write_to=f\"plot-{THEME}.png\")\n"}