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.

""" anyplot.ai
swimmer-clinical-timeline: Swimmer Plot for Clinical Trial Timelines
Library: plotnine 0.15.5 | Python 3.13.13
Quality: 91/100 | Updated: 2026-06-08
"""
import os
import numpy as np
import pandas as pd
from plotnine import (
aes,
annotate,
coord_cartesian,
element_blank,
element_line,
element_rect,
element_text,
geom_point,
geom_segment,
geom_vline,
ggplot,
guide_legend,
guides,
labs,
scale_color_manual,
scale_fill_manual,
scale_shape_manual,
scale_x_continuous,
scale_y_discrete,
theme,
theme_minimal,
)
# Theme tokens — Imprint palette, theme-adaptive chrome
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"
# Data — Simulated Phase II oncology trial with 25 patients across two arms
np.random.seed(42)
n_patients = 25
patient_ids = [f"PT-{i + 1:03d}" for i in range(n_patients)]
arms = np.random.choice(["Arm A (Combo)", "Arm B (Mono)"], size=n_patients, p=[0.52, 0.48])
durations = np.round(np.random.uniform(4, 48, size=n_patients), 1)
durations = np.sort(durations)[::-1]
ongoing = np.random.choice([True, False], size=n_patients, p=[0.24, 0.76])
bar_df = pd.DataFrame({"patient_id": patient_ids, "duration": durations, "arm": arms, "ongoing": ongoing})
bar_df = bar_df.sort_values("duration", ascending=True).reset_index(drop=True)
bar_df["patient_id"] = pd.Categorical(bar_df["patient_id"], categories=bar_df["patient_id"].tolist(), ordered=True)
events = []
for _, row in bar_df.iterrows():
pid = row["patient_id"]
dur = row["duration"]
if np.random.random() < 0.60:
t = np.round(np.random.uniform(4, min(dur * 0.5, 16)), 1)
events.append({"patient_id": pid, "time": t, "event_type": "Partial Response"})
if np.random.random() < 0.25:
t = np.round(np.random.uniform(min(dur * 0.3, 12), min(dur * 0.7, 30)), 1)
events.append({"patient_id": pid, "time": t, "event_type": "Complete Response"})
if np.random.random() < 0.35:
t = np.round(np.random.uniform(dur * 0.5, dur * 0.95), 1)
events.append({"patient_id": pid, "time": t, "event_type": "Progressive Disease"})
if row["ongoing"]:
events.append({"patient_id": pid, "time": dur, "event_type": "Ongoing"})
events_df = pd.DataFrame(events)
events_df["patient_id"] = pd.Categorical(
events_df["patient_id"], categories=bar_df["patient_id"].tolist(), ordered=True
)
# Summary statistics for storytelling annotations
median_a = bar_df.loc[bar_df["arm"] == "Arm A (Combo)", "duration"].median()
median_b = bar_df.loc[bar_df["arm"] == "Arm B (Mono)", "duration"].median()
n_responders = events_df[events_df["event_type"].isin(["Partial Response", "Complete Response"])][
"patient_id"
].nunique()
response_rate = n_responders / n_patients * 100
# Imprint palette — arms use positions 1-2, events use positions 3/4/5 + muted anchor
arm_colors = {
"Arm A (Combo)": "#009E73", # Imprint position 1 — brand green
"Arm B (Mono)": "#C475FD", # Imprint position 2 — lavender
}
event_colors = {
"Partial Response": "#4467A3", # Imprint position 3 — blue
"Complete Response": "#BD8233", # Imprint position 4 — ochre
"Progressive Disease": "#AE3030", # Imprint position 5 — matte red (semantic: bad outcome)
"Ongoing": INK_MUTED, # semantic anchor — neutral/ongoing
}
event_shapes = {"Partial Response": "^", "Complete Response": "D", "Progressive Disease": "s", "Ongoing": ">"}
title = "swimmer-clinical-timeline · python · plotnine · anyplot.ai"
# Plot
plot = (
ggplot()
# Median reference lines for storytelling
+ geom_vline(xintercept=median_a, linetype="dashed", color=arm_colors["Arm A (Combo)"], alpha=0.45, size=0.5)
+ geom_vline(xintercept=median_b, linetype="dotted", color=arm_colors["Arm B (Mono)"], alpha=0.45, size=0.5)
# Patient bars colored by treatment arm
+ geom_segment(
data=bar_df,
mapping=aes(x=0, xend="duration", y="patient_id", yend="patient_id", color="arm"),
size=4,
lineend="butt",
)
# Clinical event markers — white stroke for contrast against bars
+ geom_point(
data=events_df,
mapping=aes(x="time", y="patient_id", shape="event_type", fill="event_type"),
size=3,
color="white",
stroke=0.5,
)
+ scale_color_manual(values=arm_colors, name="Treatment Arm")
+ scale_shape_manual(values=event_shapes, name="Clinical Event")
+ scale_fill_manual(values=event_colors, name="Clinical Event")
+ scale_y_discrete(limits=bar_df["patient_id"].tolist())
+ scale_x_continuous(breaks=range(0, 55, 6))
+ coord_cartesian(xlim=(0, max(durations) + 2))
+ guides(
color=guide_legend(order=1, override_aes={"size": 4}),
shape=guide_legend(order=2, override_aes={"size": 3, "stroke": 0.3}),
fill=guide_legend(order=2),
)
# Median annotations — colored text, theme-adaptive fill
+ annotate(
"label",
x=median_a + 0.5,
y=2,
label=f"Median A: {median_a:.0f}w",
size=2.5,
color=arm_colors["Arm A (Combo)"],
fill=ELEVATED_BG,
fontweight="bold",
ha="left",
va="center",
label_padding=0.3,
)
+ annotate(
"label",
x=median_b + 0.5,
y=4,
label=f"Median B: {median_b:.0f}w",
size=2.5,
color=arm_colors["Arm B (Mono)"],
fill=ELEVATED_BG,
fontweight="bold",
ha="left",
va="center",
label_padding=0.3,
)
+ annotate(
"label",
x=max(durations) - 1,
y=n_patients - 1,
label=f"ORR: {response_rate:.0f}% ({n_responders}/{n_patients})",
size=2.8,
color=arm_colors["Arm A (Combo)"],
fill=ELEVATED_BG,
ha="right",
va="top",
alpha=0.95,
label_padding=0.5,
)
+ labs(title=title, x="Time on Study (weeks)", y="Patient ID")
+ theme_minimal()
+ theme(
figure_size=(8, 4.5),
plot_title=element_text(size=12, weight="bold", color=INK, margin={"b": 8}),
axis_title_x=element_text(size=10, color=INK, margin={"t": 6}),
axis_title_y=element_text(size=10, color=INK, margin={"r": 6}),
axis_text_x=element_text(size=8, color=INK_SOFT),
axis_text_y=element_text(size=9, color=INK_SOFT, family="monospace"),
legend_title=element_text(size=8, weight="bold", color=INK),
legend_text=element_text(size=8, color=INK_SOFT),
legend_position="right",
legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT, size=0.3),
legend_key=element_rect(fill=PAGE_BG, color="none"),
legend_key_size=12,
panel_grid_major_y=element_blank(),
panel_grid_minor=element_blank(),
panel_grid_major_x=element_line(color=INK, size=0.2, alpha=0.12),
panel_border=element_blank(),
plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
panel_background=element_rect(fill=PAGE_BG),
)
)
# Save
plot.save(f"plot-{THEME}.png", dpi=400, width=8, height=4.5, units="in")
Part of Swimmer Plot for Clinical Trial Timelines on anyplot.ai.