Epidemic Curve (Epi Curve) — Altair

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 Altair

Python source (Altair)

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

import os

import altair as alt
import numpy as np
import pandas as pd
from PIL import Image


# Theme tokens (Imprint palette — see 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"

# Imprint palette — case classification + cumulative line
CONFIRMED_COLOR = "#009E73"  # Imprint position 1 — brand green
PROBABLE_COLOR = "#C475FD"  # Imprint position 2 — lavender
SUSPECT_COLOR = "#4467A3"  # Imprint position 3 — blue
CUMULATIVE_COLOR = "#BD8233"  # Imprint position 4 — ochre (cumulative line)

# Data
np.random.seed(42)

dates = pd.date_range("2024-01-15", periods=120, freq="D")

days = np.arange(120)
wave1 = 80 * np.exp(-0.5 * ((days - 25) / 6) ** 2)
wave2 = 45 * np.exp(-0.5 * ((days - 55) / 8) ** 2)
wave3 = 25 * np.exp(-0.5 * ((days - 85) / 10) ** 2)
base_rate = wave1 + wave2 + wave3 + 2

confirmed_frac = np.clip(0.6 + 0.2 * np.sin(days / 15), 0.4, 0.85)
probable_frac = np.clip(0.25 - 0.05 * np.sin(days / 15), 0.1, 0.35)

total_cases = np.round(base_rate + np.random.poisson(2, 120)).astype(int)
confirmed = np.round(total_cases * confirmed_frac).astype(int)
probable = np.round(total_cases * probable_frac).astype(int)
suspect = np.clip(total_cases - confirmed - probable, 0, None).astype(int)

df = pd.DataFrame(
    {
        "onset_date": np.tile(dates, 3),
        "case_count": np.concatenate([confirmed, probable, suspect]),
        "case_type": ["Confirmed"] * 120 + ["Probable"] * 120 + ["Suspect"] * 120,
    }
)

# Cumulative totals
daily_total = pd.DataFrame({"onset_date": dates, "daily_total": total_cases})
daily_total["cumulative"] = daily_total["daily_total"].cumsum()
max_daily = int(total_cases.max()) + 15

# Intervention events — staggered y positions to avoid bar interference
events = pd.DataFrame(
    {
        "date": pd.to_datetime(["2024-02-10", "2024-03-01", "2024-03-20"]),
        "event": ["Source identified", "Containment measures", "Vaccination campaign"],
        "y_pos": [max_daily * 0.90, max_daily * 0.73, max_daily * 0.56],
    }
)

# Stacked bars with Imprint palette
type_order = ["Confirmed", "Probable", "Suspect"]
color_scale = alt.Scale(domain=type_order, range=[CONFIRMED_COLOR, PROBABLE_COLOR, SUSPECT_COLOR])

bars = (
    alt.Chart(df)
    .mark_bar(stroke=PAGE_BG, strokeWidth=0.5)
    .encode(
        x=alt.X(
            "onset_date:T",
            title="Date of Symptom Onset",
            axis=alt.Axis(format="%b %d", labelAngle=-45, tickCount="week"),
        ),
        y=alt.Y("case_count:Q", title="New Cases", scale=alt.Scale(domain=[0, max_daily])),
        color=alt.Color("case_type:N", scale=color_scale, sort=type_order, title="Classification"),
        order=alt.Order("order:Q"),
        tooltip=[
            alt.Tooltip("onset_date:T", title="Date", format="%b %d, %Y"),
            alt.Tooltip("case_type:N", title="Type"),
            alt.Tooltip("case_count:Q", title="Cases"),
        ],
    )
    .transform_calculate(order="{'Confirmed': 0, 'Probable': 1, 'Suspect': 2}[datum.case_type]")
)

# Cumulative line with independent right y-axis
cumulative_line = (
    alt.Chart(daily_total)
    .mark_line(strokeWidth=2.5, interpolate="monotone", color=CUMULATIVE_COLOR)
    .encode(
        x="onset_date:T",
        y=alt.Y(
            "cumulative:Q",
            title="Cumulative Cases",
            axis=alt.Axis(titleColor=CUMULATIVE_COLOR, labelColor=CUMULATIVE_COLOR, format=",.0f"),
        ),
        tooltip=[
            alt.Tooltip("onset_date:T", title="Date", format="%b %d, %Y"),
            alt.Tooltip("cumulative:Q", title="Cumulative Cases", format=","),
        ],
    )
)

# Vertical intervention rules
rules = alt.Chart(events).mark_rule(strokeDash=[6, 4], strokeWidth=1.5, color=INK_SOFT).encode(x="date:T")

# Event labels — horizontal at staggered heights, avoiding bar interference
rule_labels = (
    alt.Chart(events)
    .mark_text(align="left", dx=4, fontSize=11, fontStyle="italic", color=INK_MUTED)
    .encode(x="date:T", y="y_pos:Q", text="event:N")
)

# Peak annotation
peak_day = int(np.argmax(total_cases))
peak_data = pd.DataFrame(
    {
        "onset_date": [dates[peak_day]],
        "peak_val": [int(total_cases[peak_day])],
        "label": [f"Peak: {int(total_cases[peak_day])} cases"],
    }
)

peak_label = (
    alt.Chart(peak_data)
    .mark_text(fontSize=11, fontWeight="bold", color=CUMULATIVE_COLOR, dy=-10, dx=30)
    .encode(x="onset_date:T", y="peak_val:Q", text="label:N")
)

# Title scaling (67-char baseline)
title_str = "histogram-epidemic · python · altair · anyplot.ai"
title_fontsize = round(16 * min(1.0, 67 / len(title_str)))

# Layer and compose
bar_layer = alt.layer(bars, rules, rule_labels, peak_label)

chart = (
    alt.layer(bar_layer, cumulative_line)
    .resolve_scale(y="independent")
    .properties(
        width=620,
        height=320,
        background=PAGE_BG,
        title=alt.Title(
            title_str,
            fontSize=title_fontsize,
            anchor="start",
            color=INK,
            subtitle="Daily new cases by classification with cumulative total",
            subtitleFontSize=10,
            subtitleColor=INK_SOFT,
        ),
    )
    .configure_view(fill=PAGE_BG, strokeWidth=0)
    .configure_axis(
        labelFontSize=10,
        titleFontSize=12,
        gridOpacity=0.15,
        gridColor=INK,
        domainColor=INK_SOFT,
        domainWidth=0,
        tickColor=INK_SOFT,
        labelColor=INK_SOFT,
        titleColor=INK,
    )
    .configure_legend(
        titleFontSize=10,
        labelFontSize=10,
        symbolSize=150,
        orient="top-right",
        fillColor=ELEVATED_BG,
        strokeColor=INK_SOFT,
        padding=6,
        cornerRadius=4,
        labelColor=INK_SOFT,
        titleColor=INK,
    )
    .configure_title(color=INK, anchor="start")
)

# Save PNG
chart.save(f"plot-{THEME}.png", scale_factor=4.0)

# Pad PNG to exact target 3200 × 1800 (altair.md canvas contract)
TW, TH = 3200, 1800
_img = Image.open(f"plot-{THEME}.png").convert("RGB")
_w, _h = _img.size
if _w > TW or _h > TH:
    raise SystemExit(
        f"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. "
        f"Shrink chart .properties(width=, height=) values and re-render."
    )
if _w < TW or _h < TH:
    _canvas = Image.new("RGB", (TW, TH), PAGE_BG)
    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))
    _canvas.save(f"plot-{THEME}.png")

chart.save(f"plot-{THEME}.html")

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

Other implementations