Epidemic Curve (Epi Curve) — Bokeh

An epidemic curve (epi curve) is a histogram showing the number of new disease cases over time, plotted by date of symptom onset. The shape of the curve reveals the outbreak pattern: a sharp peak indicates a point source, successive waves suggest propagated transmission, and a plateau indicates continuous exposure. It is fundamental to epidemiological investigation and public health surveillance.

Epidemic Curve (Epi Curve) rendered with Bokeh

Python source (Bokeh)

""" anyplot.ai
histogram-epidemic: Epidemic Curve (Epi Curve)
Library: bokeh 3.9.0 | Python 3.13.13
Quality: 93/100 | Updated: 2026-06-02
"""

import os
import sys


# Prevent this file (bokeh.py) from shadowing the installed bokeh package when
# Python prepends the script's directory to sys.path at startup.
_here = os.path.dirname(os.path.abspath(__file__))
sys.path = [p for p in sys.path if os.path.abspath(p) != _here]

import time
from pathlib import Path

import numpy as np
import pandas as pd
from bokeh.io import output_file, save
from bokeh.models import (
    ColumnDataSource,
    HoverTool,
    Label,
    Legend,
    LegendItem,
    LinearAxis,
    NumeralTickFormatter,
    Range1d,
    Span,
)
from bokeh.plotting import figure
from selenium import webdriver
from selenium.webdriver.chrome.options import Options


# Theme tokens (Imprint palette — default-style-guide.md "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"

# Imprint categorical palette — position 1 is ALWAYS first series
IMPRINT_PALETTE = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314"]

# Data — simulated foodborne illness outbreak over 90 days
np.random.seed(42)

start_date = pd.Timestamp("2024-01-15")
dates = pd.date_range(start_date, periods=90, freq="D")
days = np.arange(90)

# Primary wave: sharp peak ~day 12 (point-source contaminated event)
confirmed_wave1 = np.random.poisson(lam=np.clip(45 * np.exp(-0.5 * ((days - 12) / 3.5) ** 2), 0.5, None))
# Secondary propagated wave ~day 35
confirmed_wave2 = np.random.poisson(lam=np.clip(20 * np.exp(-0.5 * ((days - 35) / 6) ** 2), 0.2, None))
# Low endemic tail
confirmed_tail = np.random.poisson(lam=np.clip(1.5 * np.exp(-0.03 * days), 0.1, None))
confirmed = confirmed_wave1 + confirmed_wave2 + confirmed_tail

probable = np.random.poisson(
    lam=np.clip(12 * np.exp(-0.5 * ((days - 14) / 4) ** 2) + 7 * np.exp(-0.5 * ((days - 37) / 7) ** 2), 0.1, None)
)
suspect = np.random.poisson(
    lam=np.clip(5 * np.exp(-0.5 * ((days - 13) / 5) ** 2) + 3 * np.exp(-0.5 * ((days - 36) / 8) ** 2), 0.05, None)
)

df = pd.DataFrame({"date": dates, "confirmed": confirmed, "probable": probable, "suspect": suspect})
df["total"] = df["confirmed"] + df["probable"] + df["suspect"]
df["cumulative"] = df["total"].cumsum()
df["date_str"] = df["date"].dt.strftime("%b %d")
bar_width = 0.8 * 24 * 60 * 60 * 1000  # 0.8 day in milliseconds

source = ColumnDataSource(
    data={
        "date": df["date"],
        "date_str": df["date_str"],
        "confirmed": df["confirmed"],
        "probable": df["probable"],
        "suspect": df["suspect"],
        "total": df["total"],
        "cumulative": df["cumulative"],
    }
)

stack_labels = ["confirmed", "probable", "suspect"]
display_labels = ["Confirmed", "Probable", "Suspect"]
colors = IMPRINT_PALETTE[:3]  # #009E73, #C475FD, #4467A3

title = "histogram-epidemic · python · bokeh · anyplot.ai"
# 49 chars < 67 baseline — no fontsize scaling needed

# Plot
p = figure(
    width=3200,
    height=1800,
    title=title,
    x_axis_label="Date of Symptom Onset",
    y_axis_label="New Cases (per day)",
    x_axis_type="datetime",
    toolbar_location=None,  # prevent ~30-50px toolbar bloat above canvas
    min_border_bottom=160,  # room for 34pt tick + 42pt axis label
    min_border_left=180,
    min_border_top=110,
    min_border_right=200,  # extra room for right secondary-axis label
)

# Stacked bars (idiomatic Bokeh)
renderers = p.vbar_stack(
    stack_labels, x="date", width=bar_width, color=colors, source=source, line_color=PAGE_BG, line_width=0.5, alpha=0.9
)

hover = HoverTool(
    renderers=list(renderers),
    tooltips=[
        ("Date", "@date_str"),
        ("Confirmed", "@confirmed"),
        ("Probable", "@probable"),
        ("Suspect", "@suspect"),
        ("Total", "@total"),
        ("Cumulative", "@cumulative{0,0}"),
    ],
    mode="vline",
)
p.add_tools(hover)

# Intervention lines — matte red (#AE3030) for source; INK_SOFT for response
contamination_date = pd.Timestamp("2024-01-27")
intervention_date = pd.Timestamp("2024-02-05")

p.add_layout(
    Span(
        location=contamination_date,
        dimension="height",
        line_color=IMPRINT_PALETTE[4],  # #AE3030 — source / error semantic
        line_width=3,
        line_dash="dashed",
        line_alpha=0.8,
    )
)
p.add_layout(
    Span(
        location=intervention_date,
        dimension="height",
        line_color=INK_SOFT,
        line_width=3,
        line_dash="dashed",
        line_alpha=0.8,
    )
)

max_cases = int(df["total"].max())

p.add_layout(
    Label(
        x=contamination_date,
        y=max_cases * 0.95,
        text="Source Identified",
        text_font_size="28pt",
        text_color=IMPRINT_PALETTE[4],
        text_font_style="bold",
        x_offset=10,
    )
)
p.add_layout(
    Label(
        x=intervention_date,
        y=max_cases * 0.82,
        text="Intervention Began",
        text_font_size="28pt",
        text_color=INK_SOFT,
        text_font_style="bold",
        x_offset=10,
    )
)

# Secondary y-axis — cumulative burden line
cumulative_max = int(df["cumulative"].max())
p.extra_y_ranges = {"cumulative": Range1d(start=0, end=cumulative_max * 1.1)}

cumulative_axis = LinearAxis(
    y_range_name="cumulative",
    axis_label="Cumulative Cases",
    axis_label_text_font_size="42pt",
    axis_label_text_color=INK,
    major_label_text_font_size="34pt",
    major_label_text_color=INK_SOFT,
    axis_line_color=INK_SOFT,
    minor_tick_line_color=None,
    major_tick_line_color=INK_SOFT,
    formatter=NumeralTickFormatter(format="0,0"),
)
p.add_layout(cumulative_axis, "right")

source_cumulative = ColumnDataSource(data={"date": df["date"], "cumulative": df["cumulative"]})
r_cumulative = p.line(
    x="date",
    y="cumulative",
    source=source_cumulative,
    line_color=INK,
    line_width=3,
    line_alpha=0.55,
    y_range_name="cumulative",
)

# Legend
legend_items = [LegendItem(label=lbl, renderers=[r]) for lbl, r in zip(display_labels, renderers, strict=False)]
legend_items.append(LegendItem(label=f"Cumulative (total: {cumulative_max:,})", renderers=[r_cumulative]))
legend = Legend(
    items=legend_items,
    location="top_right",
    label_text_font_size="34pt",
    label_text_color=INK_SOFT,
    glyph_width=50,
    glyph_height=30,
    spacing=14,
    padding=20,
    background_fill_alpha=0.9,
    background_fill_color=ELEVATED_BG,
    border_line_color=INK_SOFT,
    border_line_alpha=0.5,
)
p.add_layout(legend, "center")

# Typography (canonical bokeh.md sizing: title 50pt, labels 42pt, ticks 34pt)
p.title.text_font_size = "50pt"
p.title.text_color = INK
p.title.text_font_style = "bold"

p.xaxis.axis_label_text_font_size = "42pt"
p.yaxis[0].axis_label_text_font_size = "42pt"
p.xaxis.axis_label_text_color = INK
p.yaxis[0].axis_label_text_color = INK

p.xaxis.major_label_text_font_size = "34pt"
p.yaxis[0].major_label_text_font_size = "34pt"
p.xaxis.major_label_text_color = INK_SOFT
p.yaxis[0].major_label_text_color = INK_SOFT
p.yaxis[0].formatter = NumeralTickFormatter(format="0,0")

# Grid
p.xgrid.visible = False
p.ygrid.grid_line_alpha = 0.15
p.ygrid.grid_line_color = INK
p.ygrid.grid_line_width = 1

# Theme-adaptive chrome
p.background_fill_color = PAGE_BG
p.border_fill_color = PAGE_BG
p.outline_line_color = None

p.xaxis.axis_line_color = INK_SOFT
p.yaxis[0].axis_line_color = INK_SOFT
p.xaxis.minor_tick_line_color = None
p.yaxis[0].minor_tick_line_color = None
p.xaxis.major_tick_line_color = INK_SOFT
p.yaxis[0].major_tick_line_color = INK_SOFT

p.y_range.start = 0
p.y_range.end = max_cases * 1.15

# Save HTML (interactive catalog artifact)
output_file(f"plot-{THEME}.html", title=title)
save(p)

# Screenshot via headless Chrome — Selenium 4 auto-resolves the driver
W, H = 3200, 1800
opts = Options()
for arg in (
    "--headless=new",
    "--no-sandbox",
    "--disable-dev-shm-usage",
    "--disable-gpu",
    f"--window-size={W},{H}",
    "--hide-scrollbars",
):
    opts.add_argument(arg)
driver = webdriver.Chrome(options=opts)
# CDP override ensures exact viewport — window-size alone is eaten by browser chrome
driver.execute_cdp_cmd(
    "Emulation.setDeviceMetricsOverride", {"width": W, "height": H, "deviceScaleFactor": 1, "mobile": False}
)
driver.get(f"file://{Path(f'plot-{THEME}.html').resolve()}")
time.sleep(3)
driver.save_screenshot(f"plot-{THEME}.png")
driver.quit()

# Belt-and-braces: pin saved PNG to exact target dims so the post-render gate passes
from PIL import Image as _PILImage


_img = _PILImage.open(f"plot-{THEME}.png").convert("RGB")
if _img.size != (W, H):
    _norm = _PILImage.new("RGB", (W, H), PAGE_BG)
    _norm.paste(_img, ((W - _img.size[0]) // 2, (H - _img.size[1]) // 2))
    _norm.save(f"plot-{THEME}.png")

Part of Epidemic Curve (Epi Curve) on anyplot.ai.

Other implementations