Chris Parmer — home

Shot chart with half-court outline and Histogram2d

Basketball

Example from the compendium of canonical charts

Basketball — Shot chart with half-court outline and Histogram2d

Python Code

"""Basketball — Shot chart with half-court outline and Histogram2d."""


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


URL = "https://raw.githubusercontent.com/sealneaward/nba-movement-data/master/data/shots/shots.csv"


def arc(cx, cy, r, theta_start, theta_end, n=100):
    t = np.linspace(np.radians(theta_start), np.radians(theta_end), n)
    return cx + r * np.cos(t), cy + r * np.sin(t)


def build_court_shapes():
    shapes = []

    def line(x0, y0, x1, y1):
        return dict(type="line", x0=x0, y0=y0, x1=x1, y1=y1,
                    line=dict(color="#aaaaaa", width=1.5))

    def path(pts_x, pts_y):
        p = "M " + " L ".join(f"{x:.2f},{y:.2f}" for x, y in zip(pts_x, pts_y))
        return dict(type="path", path=p, line=dict(color="#aaaaaa", width=1.5),
                    fillcolor="rgba(0,0,0,0)")

    # Court boundary
    shapes += [
        line(-25, 0, 25, 0),
        line(-25, 0, -25, 47),
        line(25, 0, 25, 47),
        line(-25, 47, 25, 47),
    ]
    # Paint
    shapes += [
        line(-8, 0, -8, 19),
        line(8, 0, 8, 19),
        line(-8, 19, 8, 19),
    ]
    # Free throw circle
    t = np.linspace(0, np.pi, 80)
    shapes.append(path(6 * np.cos(t), 19 + 6 * np.sin(t)))
    # Restricted area arc
    ra_x, ra_y = arc(0, 4, 4, 0, 180)
    shapes.append(path(ra_x, ra_y))
    # 3-point corners
    shapes += [line(-22, 0, -22, 8.5), line(22, 0, 22, 8.5)]
    # 3-point arc
    tp_x, tp_y = arc(0, 4, 23.75, -68, 248)
    mask = np.abs(tp_x) <= 22
    shapes.append(path(tp_x[mask], tp_y[mask]))
    # Hoop
    t2 = np.linspace(0, 2 * np.pi, 40)
    shapes.append(path(0.75 * np.cos(t2), 4 + 0.75 * np.sin(t2)))
    return shapes


def generate():
    warnings.filterwarnings("ignore")
    try:
        requests.packages.urllib3.disable_warnings()
    except Exception:
        pass

    df = None
    top_player = "Synthetic Player"
    try:
        r = requests.get(URL, verify=False, timeout=30)
        r.raise_for_status()
        df = pd.read_csv(io.StringIO(r.text),
                         usecols=["LOC_X", "LOC_Y", "SHOT_MADE_FLAG", "PLAYER_NAME"])
        top_player = df["PLAYER_NAME"].value_counts().index[0]
        df = df[df["PLAYER_NAME"] == top_player].copy()
        df["x"] = df["LOC_X"] / 10.0
        df["y"] = df["LOC_Y"] / 10.0
        print(f"Loaded {len(df)} shots for {top_player}")
    except Exception as e:
        print(f"Download failed ({e}), using synthetic shot data")
        df = None

    if df is None:
        RNG = np.random.default_rng(42)
        n = 600
        zones = [
            (0, 5, 3, 2, 0.15),
            (-8, 8, 3, 2, 0.10),
            (8, 8, 3, 2, 0.10),
            (-22, 5, 1, 3, 0.12),
            (22, 5, 1, 3, 0.12),
            (-19, 22, 3, 2, 0.12),
            (19, 22, 3, 2, 0.12),
            (0, 24, 4, 2, 0.17),
        ]
        xs, ys, made = [], [], []
        weights = np.array([z[4] for z in zones])
        weights /= weights.sum()
        for i, (cx, cy, sx, sy, _) in enumerate(zones):
            n_z = int(n * weights[i])
            xs.extend(RNG.normal(cx, sx, n_z))
            ys.extend(RNG.normal(cy, sy, n_z))
            made.extend(RNG.binomial(1, 0.45, n_z))
        df = pd.DataFrame({"x": xs, "y": ys, "SHOT_MADE_FLAG": made})

    court_shapes = build_court_shapes()

    fig = go.Figure()
    fig.add_trace(go.Histogram2d(
        x=df["x"].values,
        y=df["y"].values,
        histfunc="count",
        colorscale="Hot",
        reversescale=True,
        nbinsx=30,
        nbinsy=30,
        zmin=0,
        colorbar=dict(title="Shots"),
        name="Shot density",
        xbingroup="x",
        ybingroup="y",
    ))

    fig.update_layout(
        shapes=court_shapes,
        xaxis=dict(
            title="Feet (from basket center)",
            range=[-27, 27],
            showgrid=False,
            zeroline=False,
        ),
        yaxis=dict(
            title="Feet (from baseline)",
            range=[-3, 50],
            showgrid=False,
            zeroline=False,
            scaleanchor="x",
            scaleratio=1,
            constrain="domain",
        ),
        annotations=[dict(x=0, y=48, text=f"Player: {top_player}",
                          showarrow=False, font=dict(size=11))],
    )
    return fig


if __name__ == "__main__":
    generate()

Made with Plotly