Swimmer Plot for Clinical Trial Timelines — Plotly

A swimmer plot displays individual patient timelines as horizontal bars, commonly used in clinical oncology to visualize treatment duration, response events, and disease progression across a study cohort. Each bar represents one patient, typically sorted by treatment duration, with symbols or color changes marking key clinical events such as partial response, complete response, or progressive disease. This plot is standard in clinical trial publications and regulatory submissions for conveying patient-level longitudinal outcomes at a glance.

Swimmer Plot for Clinical Trial Timelines rendered with Plotly

Renders

Python source (Plotly)

""" anyplot.ai
swimmer-clinical-timeline: Swimmer Plot for Clinical Trial Timelines
Library: plotly 6.8.0 | Python 3.13.13
Quality: 88/100 | Created: 2026-06-08
"""

import os

import numpy as np
import plotly.graph_objects as go


# Theme tokens (see prompts/default-style-guide.md)
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
GRID = "rgba(26,26,23,0.15)" if THEME == "light" else "rgba(240,239,232,0.15)"

# Imprint palette — first series always #009E73 (brand green)
IMPRINT_PALETTE = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314"]
ANYPLOT_AMBER = "#DDCC77"  # adverse events / warning

ARM_COLORS = {"Arm A": IMPRINT_PALETTE[0], "Arm B": IMPRINT_PALETTE[1]}

EVENT_CONFIG = {
    "partial_response": {"symbol": "triangle-up", "color": IMPRINT_PALETTE[5], "name": "Partial Response"},
    "complete_response": {"symbol": "star", "color": IMPRINT_PALETTE[2], "name": "Complete Response"},
    "progressive_disease": {"symbol": "diamond", "color": IMPRINT_PALETTE[4], "name": "Progressive Disease"},
    "adverse_event": {"symbol": "circle", "color": ANYPLOT_AMBER, "name": "Adverse Event"},
}

# Data — simulated Phase II oncology trial, 25 patients across two treatment arms
np.random.seed(42)

n_patients = 25
patient_ids = [f"PT-{i:03d}" for i in range(1, n_patients + 1)]
arm_labels = ["Arm A"] * 12 + ["Arm B"] * 13

durations = np.concatenate([np.random.uniform(8, 52, 12), np.random.uniform(4, 48, 13)])
ongoing_flags = np.random.choice([True, False], n_patients, p=[0.3, 0.7])

event_type_keys = list(EVENT_CONFIG.keys())
patient_events = []
for i in range(n_patients):
    dur = durations[i]
    n_ev = np.random.randint(1, 4)
    evs = []
    for _ in range(n_ev):
        et = event_type_keys[np.random.randint(len(event_type_keys))]
        t = np.random.uniform(1.0, max(dur * 0.85, 1.5))
        evs.append((et, t))
    patient_events.append(evs)

# Sort ascending by duration — plotly categorical y-axis places first entry at bottom,
# last at top, so ascending sort puts the longest-duration patient at the top
sort_idx = np.argsort(durations)
sorted_ids = [patient_ids[i] for i in sort_idx]
sorted_arms = [arm_labels[i] for i in sort_idx]
sorted_durs = [float(durations[i]) for i in sort_idx]
sorted_ongoing = [bool(ongoing_flags[i]) for i in sort_idx]
sorted_events = [patient_events[i] for i in sort_idx]

median_dur = float(np.median(sorted_durs))

# Plot
fig = go.Figure()

# Patient duration bars — each bar colored by treatment arm
bar_colors = [ARM_COLORS[arm] for arm in sorted_arms]
fig.add_trace(
    go.Bar(
        x=sorted_durs,
        y=sorted_ids,
        orientation="h",
        marker={"color": bar_colors, "opacity": 0.75, "line": {"width": 0}},
        width=0.65,
        showlegend=False,
        hovertemplate="%{y}: %{x:.1f} wk<extra></extra>",
    )
)

# Dummy bar traces for treatment arm legend entries
for arm, color in ARM_COLORS.items():
    fig.add_trace(go.Bar(x=[None], y=[None], orientation="h", name=arm, marker={"color": color, "opacity": 0.75}))

# Clinical event markers — one scatter trace per event type; size=11 reduces visual clutter
for et_key, config in EVENT_CONFIG.items():
    ex, ey = [], []
    for j, evs in enumerate(sorted_events):
        for et, t in evs:
            if et == et_key:
                ex.append(t)
                ey.append(sorted_ids[j])
    if ex:
        fig.add_trace(
            go.Scatter(
                x=ex,
                y=ey,
                mode="markers",
                name=config["name"],
                marker={"symbol": config["symbol"], "size": 11, "color": config["color"], "line": {"color": INK, "width": 1.0}},
                hovertemplate=f"{config['name']}: %{{x:.1f}} wk<extra></extra>",
            )
        )

# Ongoing patients: right-pointing triangle placed just beyond bar end
ong_x = [sorted_durs[j] + 0.4 for j in range(n_patients) if sorted_ongoing[j]]
ong_y = [sorted_ids[j] for j in range(n_patients) if sorted_ongoing[j]]
if ong_x:
    fig.add_trace(
        go.Scatter(
            x=ong_x,
            y=ong_y,
            mode="markers",
            name="Ongoing",
            marker={"symbol": "triangle-right", "size": 12, "color": INK, "line": {"color": INK, "width": 0.5}},
            hovertemplate="Still on study<extra></extra>",
        )
    )

# Median duration reference line — data storytelling focal point
fig.add_vline(
    x=median_dur,
    line={"color": INK_MUTED, "width": 1.5, "dash": "dot"},
    annotation_text=f"Median {median_dur:.1f} wk",
    annotation_position="top right",
    annotation_font={"size": 9, "color": INK_MUTED},
    annotation_bgcolor=ELEVATED_BG,
    annotation_bordercolor=INK_MUTED,
    annotation_borderwidth=1,
    annotation_borderpad=3,
)

# Style
title = "swimmer-clinical-timeline · python · plotly · anyplot.ai"

fig.update_layout(
    autosize=False,
    paper_bgcolor=PAGE_BG,
    plot_bgcolor=PAGE_BG,
    barmode="overlay",
    title={"text": title, "font": {"size": 16, "color": INK}, "x": 0.5, "xanchor": "center"},
    xaxis={
        "title": {"text": "Weeks on Study", "font": {"size": 12, "color": INK}},
        "tickfont": {"size": 10, "color": INK_SOFT},
        "gridcolor": GRID,
        "linecolor": INK_SOFT,
        "zerolinecolor": INK_SOFT,
        "showgrid": True,
        "showline": True,
        "mirror": False,
        "range": [0, 57],
    },
    yaxis={
        "title": {"text": "Patient ID", "font": {"size": 12, "color": INK}},
        "tickfont": {"size": 10, "color": INK_SOFT},
        "linecolor": INK_SOFT,
        "showgrid": False,
        "showline": True,
        "mirror": False,
        "tickmode": "array",
        "tickvals": sorted_ids,
        "ticktext": sorted_ids,
    },
    legend={
        "bgcolor": ELEVATED_BG,
        "bordercolor": INK_SOFT,
        "borderwidth": 1,
        "font": {"size": 10, "color": INK_SOFT},
        "x": 1.02,
        "xanchor": "left",
        "y": 1.0,
        "yanchor": "top",
    },
    margin={"l": 90, "r": 170, "t": 80, "b": 60},
)

# Save
fig.write_image(f"plot-{THEME}.png", width=800, height=450, scale=4)
fig.write_html(f"plot-{THEME}.html", include_plotlyjs="cdn")

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/swimmer-clinical-timeline/plotly/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "swimmer-clinical-timeline",
  "language": "python",
  "library": "plotly",
  "page": "https://anyplot.ai/swimmer-clinical-timeline/python/plotly",
  "hub": "https://anyplot.ai/swimmer-clinical-timeline",
  "code_json": "https://api.anyplot.ai/specs/swimmer-clinical-timeline/plotly/code",
  "spec_json": "https://api.anyplot.ai/specs/swimmer-clinical-timeline",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/swimmer-clinical-timeline/python/plotly/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/swimmer-clinical-timeline/python/plotly/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/swimmer-clinical-timeline/python/plotly/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/swimmer-clinical-timeline/python/plotly/plot-dark.html",
  "quality_score": 88.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Swimmer Plot for Clinical Trial Timelines on anyplot.ai.

Other implementations