Epidemic Curve (Epi Curve) — lets-plot

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 lets-plot

Python source (lets-plot)

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

import os

import numpy as np
import pandas as pd
from lets_plot import (
    LetsPlot,
    aes,
    element_blank,
    element_line,
    element_rect,
    element_text,
    geom_bar,
    geom_line,
    geom_text,
    geom_vline,
    ggplot,
    ggsize,
    labs,
    layer_tooltips,
    scale_fill_manual,
    scale_x_datetime,
    scale_y_continuous,
    theme,
    theme_minimal,
)
from lets_plot.export import ggsave


LetsPlot.setup_html()

# 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"
GRID_COLOR = "#E0DED8" if THEME == "light" else "#2A2A27"

# Imprint categorical palette — first series always #009E73
IMPRINT_PALETTE = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314"]
COLOR_LOCKDOWN = IMPRINT_PALETTE[4]  # matte red — restriction / caution
COLOR_VACC = IMPRINT_PALETTE[5]  # cyan — health intervention

# Data — simulated foodborne outbreak with point-source shape
np.random.seed(42)

outbreak_start = pd.Timestamp("2024-03-01")
n_days = 45
dates = pd.date_range(outbreak_start, periods=n_days, freq="D")

days = np.arange(n_days)
confirmed_rate = 120 * np.exp(-0.5 * ((np.log(days + 1) - np.log(12)) / 0.45) ** 2)
probable_rate = 35 * np.exp(-0.5 * ((np.log(days + 1) - np.log(14)) / 0.5) ** 2)
suspect_rate = 15 * np.exp(-0.5 * ((np.log(days + 1) - np.log(10)) / 0.55) ** 2)

confirmed_counts = np.random.poisson(np.maximum(confirmed_rate, 0.1)).astype(int)
probable_counts = np.random.poisson(np.maximum(probable_rate, 0.1)).astype(int)
suspect_counts = np.random.poisson(np.maximum(suspect_rate, 0.1)).astype(int)

df = pd.DataFrame(
    {
        "onset_date": np.tile(dates, 3),
        "case_count": np.concatenate([confirmed_counts, probable_counts, suspect_counts]),
        "case_type": ["Confirmed"] * n_days + ["Probable"] * n_days + ["Suspect"] * n_days,
    }
)

# Cumulative overlay line (scaled to share primary y-axis)
daily_total = confirmed_counts + probable_counts + suspect_counts
cumulative = np.cumsum(daily_total)
max_daily = int(daily_total.max())
max_cumulative = int(cumulative[-1])
scale_factor = max_daily * 0.90 / max_cumulative

df_cumulative = pd.DataFrame({"onset_date": dates, "scaled_cumulative": cumulative * scale_factor})

# Intervention markers — ms timestamps required for lets-plot datetime axis
lockdown_date = pd.Timestamp("2024-03-15")
vaccination_date = pd.Timestamp("2024-04-05")
lockdown_ms = lockdown_date.timestamp() * 1000
vaccination_ms = vaccination_date.timestamp() * 1000

df_ann_lockdown = pd.DataFrame({"onset_date": [lockdown_date], "y_pos": [max_daily * 0.94], "label": ["Lockdown"]})
df_ann_vacc = pd.DataFrame({"onset_date": [vaccination_date], "y_pos": [max_daily * 0.84], "label": ["Vaccination"]})

# Weekly x-axis breaks
weekly_dates = pd.date_range(outbreak_start, periods=7, freq="7D")
weekly_ms = [d.timestamp() * 1000 for d in weekly_dates]

# Title fontsize scaled for length (floor 11px per lets-plot family)
title = "histogram-epidemic · python · letsplot · anyplot.ai"
n_chars = len(title)
ratio = 67 / n_chars if n_chars > 67 else 1.0
title_size = max(11, round(16 * ratio))

# Plot
plot = (
    ggplot(df, aes(x="onset_date", y="case_count", fill="case_type"))
    + geom_bar(
        stat="identity", position="stack", width=0.8, tooltips=layer_tooltips().line("@case_type|@case_count cases")
    )
    + geom_line(
        data=df_cumulative,
        mapping=aes(x="onset_date", y="scaled_cumulative"),
        color=INK_SOFT,
        size=1.2,
        alpha=0.75,
        inherit_aes=False,
    )
    + geom_vline(xintercept=lockdown_ms, color=COLOR_LOCKDOWN, size=1.0, linetype="dashed")
    + geom_vline(xintercept=vaccination_ms, color=COLOR_VACC, size=1.0, linetype="dashed")
    + geom_text(
        data=df_ann_lockdown,
        mapping=aes(x="onset_date", y="y_pos", label="label"),
        color=COLOR_LOCKDOWN,
        size=4,
        fontface="bold",
        hjust=0,
        nudge_x=80000000,
        inherit_aes=False,
    )
    + geom_text(
        data=df_ann_vacc,
        mapping=aes(x="onset_date", y="y_pos", label="label"),
        color=COLOR_VACC,
        size=4,
        fontface="bold",
        hjust=1,
        nudge_x=-80000000,
        inherit_aes=False,
    )
    + scale_fill_manual(
        values={"Confirmed": IMPRINT_PALETTE[0], "Probable": IMPRINT_PALETTE[1], "Suspect": IMPRINT_PALETTE[2]},
        name="Case Classification",
    )
    + scale_x_datetime(name="Date of Symptom Onset", format="%b %d", breaks=weekly_ms)
    + scale_y_continuous(name="Daily New Cases", format="d")
    + labs(
        title=title,
        subtitle="Foodborne outbreak epi curve — daily cases by classification with cumulative trend",
        caption=f"Grey line = cumulative cases (scaled to axis)  ·  {max_cumulative:,} total cases",
    )
    + theme_minimal()
    + theme(
        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        panel_background=element_rect(fill=PAGE_BG),
        plot_title=element_text(size=title_size, color=INK, face="bold"),
        plot_subtitle=element_text(size=12, color=INK_SOFT),
        axis_title=element_text(size=12, color=INK),
        axis_text=element_text(size=10, color=INK_SOFT),
        legend_title=element_text(size=10, color=INK, face="bold"),
        legend_text=element_text(size=10, color=INK_SOFT),
        plot_caption=element_text(size=9, color=INK_MUTED, hjust=0.5),
        legend_position=[0.82, 0.28],
        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT, size=0.3),
        panel_grid_major_x=element_blank(),
        panel_grid_minor=element_blank(),
        panel_grid_major_y=element_line(color=GRID_COLOR, size=0.5),
    )
    + ggsize(800, 450)
)

# Save
ggsave(plot, f"plot-{THEME}.png", path=".", scale=4)
ggsave(plot, f"plot-{THEME}.html", path=".")

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

Other implementations