Win Probability Chart — Bokeh

A win probability chart shows how each team's likelihood of winning evolves over the course of a game. The line starts near 50% and fluctuates based on scoring events, ultimately reaching 100% or 0% at game end. The area above and below the 50% baseline is filled with team colors to convey momentum at a glance. This visualization is widely used across major sports for post-game analysis and live broadcasting.

Win Probability Chart rendered with Bokeh

Python source (Bokeh)

""" anyplot.ai
line-win-probability: Win Probability Chart
Library: bokeh 3.9.1 | Python 3.13.14
Quality: 90/100 | Updated: 2026-06-21
"""

import sys


# Running as `python bokeh.py` inserts the script directory into sys.path[0],
# shadowing the installed bokeh package. Remove it before any bokeh imports.
sys.path.pop(0)

import os
import time
from pathlib import Path

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


# 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"

# Team colors — semantic exception: real NFL team identity colors
EAGLES_COLOR = "#004C54"  # Eagles midnight green
COWBOYS_COLOR = "#869397"  # Cowboys silver

# Data — simulated NFL game: Eagles vs Cowboys
np.random.seed(42)

plays = np.arange(0, 121)
win_prob = np.full(121, 0.50)

events = {
    8: ("FG Eagles 3-0", 0.62),
    22: ("TD Cowboys 3-7", 0.38),
    35: ("TD Eagles 10-7", 0.58),
    48: ("FG Cowboys 10-10", 0.50),
    55: ("TD Eagles 17-10", 0.68),
    72: ("TD Cowboys 17-17", 0.48),
    85: ("FG Eagles 20-17", 0.63),
    95: ("INT Eagles", 0.72),
    105: ("TD Cowboys 20-24", 0.30),
    112: ("TD Eagles 27-24", 0.88),
    118: ("Turnover on downs", 0.97),
}

current_prob = 0.50
for i in range(1, 121):
    if i in events:
        current_prob = events[i][1]
    else:
        drift = np.random.normal(0, 0.015)
        current_prob = np.clip(current_prob + drift, 0.03, 0.97)
    win_prob[i] = current_prob

win_prob[120] = 1.0

win_prob_smooth = np.convolve(win_prob, np.ones(3) / 3, mode="same")
win_prob_smooth[0] = 0.50
win_prob_smooth[120] = 1.0
for play in events:
    win_prob_smooth[play] = win_prob[play]

upper = np.maximum(win_prob_smooth, 0.50)
lower = np.minimum(win_prob_smooth, 0.50)

source = ColumnDataSource(
    data={
        "play": plays,
        "win_prob": win_prob_smooth,
        "upper": upper,
        "lower": lower,
        "baseline": np.full(121, 0.50),
        "pct": win_prob_smooth * 100,
    }
)

# Title — 50 chars, well under 67 baseline, no scaling needed
title = "line-win-probability · python · bokeh · anyplot.ai"

# Plot — 3200×1800 landscape; toolbar_location=None keeps canvas at exact height
p = figure(
    width=3200,
    height=1800,
    title=title,
    x_axis_label="Play Number",
    y_axis_label="Win Probability (%)",
    y_range=(-0.02, 1.02),
    x_range=(-3, 126),
    toolbar_location=None,
    min_border_bottom=160,
    min_border_left=180,
    min_border_top=110,
    min_border_right=50,
)

# Quarter alternating bands — BoxAnnotation is idiomatic Bokeh
quarter_boundaries = [(0, 30), (30, 60), (60, 90), (90, 120)]
quarter_names = ["Q1", "Q2", "Q3", "Q4"]
for idx, (q_start, q_end) in enumerate(quarter_boundaries):
    if idx % 2 == 1:
        p.add_layout(BoxAnnotation(left=q_start, right=q_end, fill_color=INK, fill_alpha=0.03, line_color=None))

# Area fills — Eagles above 50%, Cowboys below 50%
eagles_fill = p.varea(x="play", y1="baseline", y2="upper", source=source, fill_color=EAGLES_COLOR, fill_alpha=0.25)
cowboys_fill = p.varea(x="play", y1="lower", y2="baseline", source=source, fill_color=COWBOYS_COLOR, fill_alpha=0.30)

# Main probability line
p.line(x="play", y="win_prob", source=source, line_color=INK, line_width=4)

# Invisible scatter layer for hover targeting
hover_scatter = p.scatter(x="play", y="win_prob", source=source, size=22, fill_alpha=0, line_alpha=0)

# Hover tool with styled HTML tooltip
hover = HoverTool(
    renderers=[hover_scatter],
    tooltips="""
    <div style="background:#2a2a2a; padding:12px 16px; border-radius:8px; color:white; font-size:16px; line-height:1.6;">
        <span style="font-weight:bold; font-size:18px;">Play @play</span><br>
        <span style="color:#66ccbb;">Win Prob: @pct{0.1}%</span>
    </div>
    """,
    mode="vline",
)
p.add_tools(hover)

# CustomJS crosshair on hover — distinctive Bokeh interactivity
crosshair_v = Span(location=0, dimension="height", line_color=EAGLES_COLOR, line_width=2, line_alpha=0.4)
p.add_layout(crosshair_v)
hover.callback = CustomJS(
    args={"span": crosshair_v}, code="const geometry = cb_data.geometry; span.location = geometry.x;"
)

# 50% reference line
p.add_layout(Span(location=0.5, dimension="width", line_color=INK_MUTED, line_width=2, line_dash=[12, 6]))

# Quarter boundary lines
for q_start, _ in quarter_boundaries[1:]:
    p.add_layout(Span(location=q_start, dimension="height", line_color=INK_MUTED, line_width=2, line_dash="dotted"))

# Quarter labels
for (q_start, q_end), q_name in zip(quarter_boundaries, quarter_names, strict=False):
    p.add_layout(
        Label(
            x=(q_start + q_end) / 2,
            y=0.97,
            text=q_name,
            text_font_size="22pt",
            text_color=INK_MUTED,
            text_align="center",
            text_font_style="bold",
        )
    )

# Team name labels near the 50% midline
p.add_layout(
    Label(
        x=2,
        y=0.52,
        text="EAGLES",
        text_font_size="20pt",
        text_color=EAGLES_COLOR,
        text_font_style="bold",
        text_alpha=0.6,
    )
)
p.add_layout(
    Label(
        x=2,
        y=0.44,
        text="COWBOYS",
        text_font_size="20pt",
        text_color=COWBOYS_COLOR,
        text_font_style="bold",
        text_alpha=0.6,
    )
)

# Key scoring event annotations — spaced to avoid Q4 crowding
# Format: (play_num, text, y_offset, x_offset)
annotations = [
    (35, "TD Eagles 10-7", -18, 14),
    (55, "TD Eagles 17-10", 0, 14),
    (105, "TD Cowboys 20-24", 0, -82),  # left side: Q4, lower probability
    (112, "TD Eagles 27-24", -20, 14),  # right side: Q4, high probability
]

event_x = [a[0] for a in annotations]
event_y = [win_prob_smooth[a[0]] for a in annotations]
p.scatter(x=event_x, y=event_y, size=14, fill_color=INK, line_color=PAGE_BG, line_width=3, alpha=0.9)
for play_num, text, y_off, x_off in annotations:
    p.add_layout(
        Label(
            x=play_num,
            y=win_prob_smooth[play_num],
            text=text,
            text_font_size="19pt",
            text_color=INK,
            text_font_style="bold",
            x_offset=x_off,
            y_offset=y_off,
            background_fill_color=ELEVATED_BG,
            background_fill_alpha=0.88,
        )
    )

# Final score annotation
p.add_layout(
    Label(
        x=50,
        y=0.07,
        text="Final: Eagles 27 — Cowboys 24",
        text_font_size="26pt",
        text_color=EAGLES_COLOR,
        text_font_style="bold",
        background_fill_color=ELEVATED_BG,
        background_fill_alpha=0.90,
    )
)

# Legend
legend = Legend(
    items=[LegendItem(label="Eagles", renderers=[eagles_fill]), LegendItem(label="Cowboys", renderers=[cowboys_fill])],
    location="top_left",
    label_text_font_size="26pt",
    label_text_color=INK_SOFT,
    glyph_height=28,
    glyph_width=38,
    spacing=12,
    border_line_color=INK_SOFT,
    background_fill_color=ELEVATED_BG,
    background_fill_alpha=0.85,
    padding=20,
)
p.add_layout(legend)

# Y-axis percentage formatter
p.yaxis.ticker = [0, 0.25, 0.50, 0.75, 1.0]
p.yaxis.formatter = NumeralTickFormatter(format="0%")

# Text sizing — canonical values for 3200×1800
p.title.text_font_size = "50pt"
p.title.text_color = INK
p.xaxis.axis_label_text_font_size = "42pt"
p.yaxis.axis_label_text_font_size = "42pt"
p.xaxis.major_label_text_font_size = "34pt"
p.yaxis.major_label_text_font_size = "34pt"
p.xaxis.axis_label_text_color = INK
p.yaxis.axis_label_text_color = INK
p.xaxis.major_label_text_color = INK_SOFT
p.yaxis.major_label_text_color = INK_SOFT

# Grid — subtle horizontal emphasis
p.xgrid.grid_line_color = INK
p.xgrid.grid_line_alpha = 0.08
p.ygrid.grid_line_color = INK
p.ygrid.grid_line_alpha = 0.12
p.ygrid.grid_line_dash = [4, 4]

# Chrome — theme-adaptive
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.axis_line_color = INK_SOFT
p.xaxis.major_tick_line_color = None
p.yaxis.major_tick_line_color = None
p.xaxis.minor_tick_line_color = None
p.yaxis.minor_tick_line_color = None

# Save — HTML (interactive) + PNG via headless Chrome (Selenium Manager auto-resolves driver)
output_file(f"plot-{THEME}.html")
save(p)

import base64


W, H = 3200, 1800
# Set the window larger than the figure so the full chart fits in the viewport;
# CDP clip captures exactly WxH regardless of browser chrome overhead.
opts = Options()
for arg in (
    "--headless=new",
    "--no-sandbox",
    "--disable-dev-shm-usage",
    "--disable-gpu",
    f"--window-size={W},{H + 200}",
    "--hide-scrollbars",
    "--force-device-scale-factor=1",
):
    opts.add_argument(arg)
driver = webdriver.Chrome(options=opts)
driver.set_window_size(W, H + 200)
driver.get(f"file://{Path(f'plot-{THEME}.html').resolve()}")
time.sleep(3)
# Capture exactly WxH from the page origin via Chrome DevTools Protocol
result = driver.execute_cdp_cmd(
    "Page.captureScreenshot", {"format": "png", "clip": {"x": 0, "y": 0, "width": W, "height": H, "scale": 1}}
)
with open(f"plot-{THEME}.png", "wb") as fout:
    fout.write(base64.b64decode(result["data"]))
driver.quit()

Part of Win Probability Chart on anyplot.ai.

Other implementations