Chris Parmer — home

Strike-zone heat map + spray chart for Mike Trout

Baseball — Statcast

Example from the compendium of canonical charts

Baseball — Strike-zone heat map + spray chart for Mike Trout (Statcast)

Python Code

"""Baseball — Strike-zone heat map + spray chart for Mike Trout (Statcast)."""


import numpy as np
import pandas as pd
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import io, requests, warnings


# Mike Trout statcast data
STATCAST_URL = (
    "https://baseballsavant.mlb.com/statcast_search/csv"
    "?all=true&type=details&batters_lookup[]=545361"
    "&game_date_gt=2018-05-01&game_date_lt=2018-05-07"
)

EVENT_COLORS = {
    "home_run": "#e63030",
    "triple": "#e67830",
    "double": TEAL,
    "single": GREEN,
    "field_out": MUTED,
    "strikeout": MUTED,
    "walk": VIOLET,
}


def make_synthetic():
    RNG = np.random.default_rng(11)
    n = 250
    plate_x = RNG.normal(0, 0.75, n)
    plate_z = RNG.normal(2.5, 0.55, n)
    sz_top = np.full(n, 3.5)
    sz_bot = np.full(n, 1.5)
    events_choices = ["single", "double", "home_run", "strikeout",
                      "field_out", "walk", None]
    hc_x = RNG.normal(125.42, 40, n)
    hc_y = RNG.normal(198.27 - 80, 40, n)
    events = RNG.choice(events_choices, n)
    return pd.DataFrame({
        "plate_x": plate_x, "plate_z": plate_z,
        "sz_top": sz_top, "sz_bot": sz_bot,
        "hc_x": hc_x, "hc_y": hc_y, "events": events,
    })


def generate():
    print("fetching Statcast pitch data …")
    df = None
    try:
        warnings.filterwarnings("ignore")
        headers = {
            "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
                          "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36"
        }
        r = requests.get(STATCAST_URL, headers=headers, verify=False, timeout=30)
        r.raise_for_status()
        df = pd.read_csv(io.StringIO(r.text))
        df = df[df["plate_x"].notna() & df["plate_z"].notna()]
        print(f"  loaded {len(df)} pitches")
    except Exception as e:
        print(f"  download failed ({e}), using synthetic pitch data")
        df = None

    if df is None:
        df = make_synthetic()

    df["spray_x"] = df["hc_x"] - 125.42
    df["spray_y"] = 198.27 - df["hc_y"]

    fig = make_subplots(
        rows=1, cols=2,
        subplot_titles=["Strike Zone Heat Map", "Spray Chart"],
        horizontal_spacing=0.12,
    )

    fig.add_trace(go.Histogram2d(
        x=df["plate_x"],
        y=df["plate_z"],
        colorscale="Hot",
        reversescale=True,
        nbinsx=20,
        nbinsy=20,
        colorbar=dict(title="Pitches", x=0.44, len=0.8),
        name="Pitch density",
    ), row=1, col=1)

    sz_top_avg = float(df["sz_top"].median()) if "sz_top" in df.columns else 3.5
    sz_bot_avg = float(df["sz_bot"].median()) if "sz_bot" in df.columns else 1.5
    fig.add_shape(type="rect",
        x0=-0.83, x1=0.83, y0=sz_bot_avg, y1=sz_top_avg,
        line=dict(color="white", width=2, dash="dash"),
        fillcolor="rgba(0,0,0,0)",
        row=1, col=1)

    contact = df[df["hc_x"].notna() & df["hc_y"].notna()].copy()

    # Outfield arc
    theta_field = np.linspace(np.radians(45), np.radians(135), 80)
    of_x = 330 * np.cos(theta_field)
    of_y = 330 * np.sin(theta_field)
    fig.add_trace(go.Scatter(
        x=np.concatenate([[0], of_x, [0]]),
        y=np.concatenate([[0], of_y, [0]]),
        mode="lines",
        line=dict(color=MUTED, width=1),
        showlegend=False,
        hoverinfo="skip",
    ), row=1, col=2)

    # Infield diamond
    diamond_x = [0, 90 * np.cos(np.radians(45)), 0, -90 * np.cos(np.radians(45)), 0]
    diamond_y = [0, 90 * np.sin(np.radians(45)), 90 * np.sqrt(2), 90 * np.sin(np.radians(45)), 0]
    fig.add_trace(go.Scatter(
        x=diamond_x, y=diamond_y,
        mode="lines",
        line=dict(color=MUTED, width=1),
        showlegend=False,
        hoverinfo="skip",
    ), row=1, col=2)

    for ev, color in EVENT_COLORS.items():
        sub = contact[contact["events"] == ev]
        if len(sub) == 0:
            continue
        fig.add_trace(go.Scatter(
            x=sub["spray_x"],
            y=sub["spray_y"],
            mode="markers",
            marker=dict(size=7, color=color, opacity=0.75),
            name=ev,
        ), row=1, col=2)

    other = contact[~contact["events"].isin(list(EVENT_COLORS.keys()) + [None])]
    if len(other):
        fig.add_trace(go.Scatter(
            x=other["spray_x"], y=other["spray_y"],
            mode="markers",
            marker=dict(size=6, color=MUTED, opacity=0.5),
            name="other",
        ), row=1, col=2)

    # Strike zone: fixed aspect ratio (1 ft x = 1 ft y)
    fig.update_xaxes(title_text="Plate X (ft)", range=[-2.0, 2.0],
                     scaleanchor="y", scaleratio=1, constrain="domain", row=1, col=1)
    fig.update_yaxes(title_text="Plate Z (ft)", range=[0.5, 5],
                     constrain="domain", row=1, col=1)
    fig.update_xaxes(title_text="", range=[-350, 350], row=1, col=2,
                     scaleanchor="y2", scaleratio=1, showgrid=False, zeroline=False)
    fig.update_yaxes(title_text="", range=[-30, 380], row=1, col=2,
                     showgrid=False, zeroline=False)

    fig.update_layout(
        legend=dict(x=1.02, y=0.9, font=dict(size=10)),
    )

    return fig


if __name__ == "__main__":
    generate()

Made with Plotly