{"spec_id":"swimmer-clinical-timeline","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nswimmer-clinical-timeline: Swimmer Plot for Clinical Trial Timelines\nLibrary: bokeh 3.9.1 | Python 3.13.13\nQuality: 92/100 | Updated: 2026-06-08\n\"\"\"\n\nimport os\nimport sys\nimport time\nfrom pathlib import Path\n\n\n# Prevent this script (bokeh.py) from shadowing the installed bokeh package when\n# Python adds its own directory to sys.path[0] on direct invocation.\nsys.path = [p for p in sys.path if os.path.abspath(p or os.getcwd()) != os.path.dirname(os.path.abspath(__file__))]\n\nimport numpy as np\nfrom bokeh.io import save\nfrom bokeh.models import ColumnDataSource, FactorRange, HoverTool, Label, Legend, LegendItem, Range1d, Span\nfrom bokeh.plotting import figure\nfrom bokeh.resources import CDN\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\n# --- Theme ---\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\n\n# Theme-adaptive chrome tokens (Imprint palette)\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint categorical palette — 8 hues, theme-independent, hybrid-v3 sort\nIMPRINT_PALETTE = [\n    \"#009E73\",  # 1: brand green  — ALWAYS first series\n    \"#C475FD\",  # 2: lavender\n    \"#4467A3\",  # 3: blue\n    \"#BD8233\",  # 4: ochre\n    \"#AE3030\",  # 5: matte red   — semantic anchor: bad / loss / error\n    \"#2ABCCD\",  # 6: cyan\n    \"#954477\",  # 7: rose\n    \"#99B314\",  # 8: lime green  — growth / recovery\n]\nANYPLOT_AMBER = \"#DDCC77\"  # warning / caution semantic anchor\n\n# --- Data: Simulated Phase II oncology trial, 25 patients, two treatment arms ---\nnp.random.seed(42)\n\nn_patients = 25\npatient_ids = [f\"PT-{i + 1:03d}\" for i in range(n_patients)]\narms = np.random.choice([\"Arm A (Combo)\", \"Arm B (Mono)\"], size=n_patients, p=[0.52, 0.48])\ndurations = np.round(np.random.exponential(scale=18, size=n_patients) + 4, 1)\ndurations = np.clip(durations, 4, 52)\n\nevent_labels_map = {\n    \"partial_response\": \"Partial Response\",\n    \"complete_response\": \"Complete Response\",\n    \"progressive_disease\": \"Progressive Disease\",\n    \"adverse_event\": \"Adverse Event\",\n}\n\nevents_time = []\nevents_type = []\nevents_patient = []\nevents_label = []\nongoing_patients = set()\n\nfor i in range(n_patients):\n    dur = durations[i]\n    patient_events = []\n\n    if np.random.random() < 0.6:\n        t = np.round(np.random.uniform(4, min(dur * 0.4, 12)), 1)\n        patient_events.append((t, \"partial_response\"))\n\n    if np.random.random() < 0.3 and dur > 16:\n        t = np.round(np.random.uniform(12, min(dur * 0.6, 24)), 1)\n        patient_events.append((t, \"complete_response\"))\n\n    if np.random.random() < 0.35:\n        t = np.round(np.random.uniform(2, dur * 0.8), 1)\n        patient_events.append((t, \"adverse_event\"))\n\n    if np.random.random() < 0.4 and dur < 30:\n        t = np.round(dur - np.random.uniform(0, 2), 1)\n        patient_events.append((t, \"progressive_disease\"))\n\n    if dur > 25 and not any(e[1] == \"progressive_disease\" for e in patient_events):\n        ongoing_patients.add(i)\n\n    for t, etype in patient_events:\n        events_time.append(t)\n        events_type.append(etype)\n        events_patient.append(patient_ids[i])\n        events_label.append(event_labels_map[etype])\n\n# Sort patients by duration (longest at top)\nsort_idx = np.argsort(durations)[::-1]\nsorted_patient_ids = [patient_ids[i] for i in sort_idx]\nsorted_durations = [durations[i] for i in sort_idx]\nsorted_arms = [arms[i] for i in sort_idx]\n\n# Arm colors: Imprint palette positions 1 and 2 (first series always #009E73)\narm_colors = {\n    \"Arm A (Combo)\": IMPRINT_PALETTE[0],  # #009E73 brand green\n    \"Arm B (Mono)\": IMPRINT_PALETTE[1],  # #C475FD lavender\n}\n\nmedian_duration = float(np.median(durations))\n\n# --- Plot ---\nW, H = 3200, 1800\n\np = figure(\n    y_range=FactorRange(*sorted_patient_ids),\n    width=W,\n    height=H,\n    title=\"swimmer-clinical-timeline · python · bokeh · anyplot.ai\",\n    x_axis_label=\"Time on Study (Weeks)\",\n    toolbar_location=None,  # must be None — toolbar adds ~30-50px, causing canvas size drift\n    min_border_bottom=160,  # room for 28pt x-tick labels + 42pt x-axis label\n    min_border_left=200,  # room for 20pt y-tick labels + 42pt y-axis label\n    min_border_top=110,  # room for 50pt title\n    min_border_right=60,\n)\n\n# Horizontal bars per treatment arm\nbars_a = bars_b = None\nfor arm_name, arm_color in arm_colors.items():\n    idx = [i for i, a in enumerate(sorted_arms) if a == arm_name]\n    source = ColumnDataSource(\n        data={\n            \"y\": [sorted_patient_ids[i] for i in idx],\n            \"right\": [sorted_durations[i] for i in idx],\n            \"arm\": [arm_name] * len(idx),\n            \"dur_str\": [f\"{sorted_durations[i]:.1f} weeks\" for i in idx],\n            \"status\": [\"Ongoing\" if sort_idx[i] in ongoing_patients else \"Completed/Progressed\" for i in idx],\n        }\n    )\n    renderer = p.hbar(\n        y=\"y\",\n        right=\"right\",\n        left=0,\n        height=0.65,\n        color=arm_color,\n        alpha=0.80,\n        line_color=PAGE_BG,\n        line_width=1.5,\n        source=source,\n    )\n    if arm_name == \"Arm A (Combo)\":\n        bars_a = renderer\n    else:\n        bars_b = renderer\n\n# HoverTool for bars (Bokeh-specific interactivity)\nbar_hover = HoverTool(\n    renderers=[bars_a, bars_b],\n    tooltips=[(\"Patient\", \"@y\"), (\"Treatment\", \"@arm\"), (\"Duration\", \"@dur_str\"), (\"Status\", \"@status\")],\n    point_policy=\"follow_mouse\",\n)\np.add_tools(bar_hover)\n\n# Event marker config — Imprint palette with semantic alignment\n# Partial response (positive): blue; Complete response (recovery): lime-green;\n# Progressive disease (bad outcome): matte red; Adverse event (caution): amber\nevent_marker_config = {\n    \"partial_response\": {\n        \"marker\": \"triangle\",\n        \"color\": IMPRINT_PALETTE[2],  # #4467A3 blue — cool positive\n        \"size\": 20,\n    },\n    \"complete_response\": {\n        \"marker\": \"star\",\n        \"color\": IMPRINT_PALETTE[7],  # #99B314 lime-green — growth / recovery\n        \"size\": 24,\n    },\n    \"progressive_disease\": {\n        \"marker\": \"diamond\",\n        \"color\": IMPRINT_PALETTE[4],  # #AE3030 matte red — bad / decline\n        \"size\": 20,\n    },\n    \"adverse_event\": {\n        \"marker\": \"square\",\n        \"color\": ANYPLOT_AMBER,  # #DDCC77 amber — warning / caution\n        \"size\": 17,\n    },\n}\n\nevent_renderers = {}\nfor etype, config in event_marker_config.items():\n    mask = [j for j in range(len(events_type)) if events_type[j] == etype]\n    if not mask:\n        continue\n    source_evt = ColumnDataSource(\n        data={\n            \"x\": [events_time[j] for j in mask],\n            \"y\": [events_patient[j] for j in mask],\n            \"event\": [events_label[j] for j in mask],\n            \"week\": [f\"Week {events_time[j]:.1f}\" for j in mask],\n        }\n    )\n    r = p.scatter(\n        x=\"x\",\n        y=\"y\",\n        source=source_evt,\n        marker=config[\"marker\"],\n        size=config[\"size\"],\n        color=config[\"color\"],\n        line_color=INK,\n        line_width=1.5,\n    )\n    event_renderers[etype] = r\n\n# HoverTool for event markers\nevt_hover = HoverTool(\n    renderers=list(event_renderers.values()),\n    tooltips=[(\"Patient\", \"@y\"), (\"Event\", \"@event\"), (\"Time\", \"@week\")],\n    point_policy=\"snap_to_data\",\n)\np.add_tools(evt_hover)\n\n# Ongoing indicators — right-pointing triangles at bar ends (more prominent)\nongoing_idx_sorted = [i for i in range(n_patients) if sort_idx[i] in ongoing_patients]\nongoing_r = None\nif ongoing_idx_sorted:\n    arrow_source = ColumnDataSource(\n        data={\n            \"x\": [sorted_durations[i] + 1.5 for i in ongoing_idx_sorted],\n            \"y\": [sorted_patient_ids[i] for i in ongoing_idx_sorted],\n        }\n    )\n    ongoing_r = p.scatter(\n        x=\"x\",\n        y=\"y\",\n        source=arrow_source,\n        marker=\"triangle\",\n        size=24,\n        angle=3 * np.pi / 2,  # 270° CCW from up = pointing right\n        color=INK_SOFT,\n        line_color=INK,\n        line_width=1.5,\n    )\n\n# Median duration reference line for visual storytelling\nmedian_span = Span(\n    location=median_duration,\n    dimension=\"height\",\n    line_color=INK_MUTED,\n    line_dash=\"dashed\",\n    line_width=2.0,\n    line_alpha=0.65,\n)\np.add_layout(median_span)\n\nmedian_label = Label(\n    x=median_duration,\n    y=1560,\n    y_units=\"screen\",\n    text=f\"Median: {median_duration:.1f} wk\",\n    text_font_size=\"22pt\",\n    text_color=INK_MUTED,\n    text_font_style=\"italic\",\n    x_offset=12,\n)\np.add_layout(median_label)\n\n# --- Theme-adaptive chrome ---\np.title.text_font_size = \"50pt\"\np.title.text_color = INK\n\np.xaxis.axis_label_text_font_size = \"42pt\"\np.xaxis.axis_label_text_color = INK\np.xaxis.major_label_text_font_size = \"28pt\"\np.xaxis.major_label_text_color = INK_SOFT\np.xaxis.axis_line_color = INK_SOFT\np.xaxis.major_tick_line_color = INK_SOFT\np.xaxis.minor_tick_line_color = None\n\np.yaxis.axis_label = \"Patient\"\np.yaxis.axis_label_text_font_size = \"42pt\"\np.yaxis.axis_label_text_color = INK\np.yaxis.major_label_text_font_size = \"24pt\"  # smaller to avoid crowding 25 labels\np.yaxis.major_label_text_color = INK_SOFT\np.yaxis.axis_line_color = INK_SOFT\np.yaxis.major_tick_line_color = INK_SOFT\np.yaxis.minor_tick_line_color = None\n\np.x_range = Range1d(-0.5, max(sorted_durations) + 5)\n\np.xgrid.grid_line_color = None\np.ygrid.grid_line_color = INK\np.ygrid.grid_line_alpha = 0.15\np.ygrid.grid_line_dash = \"solid\"\n\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = None\n\n# Legend\nlegend_items = [\n    LegendItem(label=\"Arm A (Combo)\", renderers=[bars_a]),\n    LegendItem(label=\"Arm B (Mono)\", renderers=[bars_b]),\n]\nfor etype, label in [\n    (\"partial_response\", \"Partial Response\"),\n    (\"complete_response\", \"Complete Response\"),\n    (\"progressive_disease\", \"Progressive Disease\"),\n    (\"adverse_event\", \"Adverse Event\"),\n]:\n    if etype in event_renderers:\n        legend_items.append(LegendItem(label=label, renderers=[event_renderers[etype]]))\n\nif ongoing_r is not None:\n    legend_items.append(LegendItem(label=\"Ongoing\", renderers=[ongoing_r]))\n\nlegend = Legend(items=legend_items, location=\"center_right\", orientation=\"vertical\")\nlegend.label_text_font_size = \"28pt\"\nlegend.label_text_color = INK_SOFT\nlegend.glyph_height = 30\nlegend.glyph_width = 30\nlegend.spacing = 10\nlegend.padding = 20\nlegend.background_fill_color = ELEVATED_BG\nlegend.background_fill_alpha = 0.92\nlegend.border_line_color = INK_SOFT\nlegend.border_line_width = 1\np.add_layout(legend)\n\n# --- Save ---\n# Interactive HTML artifact (catalog requirement)\nsave(p, filename=f\"plot-{THEME}.html\", resources=CDN, title=\"Swimmer Clinical Timeline\")\n\n# Static PNG via headless Chrome (Selenium — avoids broken chromedriver snap path)\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\ndriver.set_window_size(W, H)\n\n# Chrome headless has ~139px of browser chrome overhead; resize so the viewport\n# (window.innerHeight) is exactly H, not H minus that overhead.\nvh = driver.execute_script(\"return window.innerHeight\")\nif vh != H:\n    driver.set_window_size(W, H + (H - vh))\n\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}