Basketball Shot Chart — Altair

A basketball shot chart overlays shooting data on a half-court diagram, plotting each shot attempt as a point colored by outcome (made or missed). The court drawing includes the three-point arc, free-throw line, paint/key area, and basket, providing spatial context for analyzing shooting patterns and efficiency. Essential for basketball analytics and player evaluation.

Basketball Shot Chart rendered with Altair

Python source (Altair)

""" anyplot.ai
scatter-shot-chart: Basketball Shot Chart
Library: altair 6.2.1 | Python 3.13.14
Quality: 90/100 | Updated: 2026-06-21
"""

import os

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


# Theme tokens
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 — semantic exception: Made=green (#009E73), Missed=matte-red (#AE3030)
MADE_COLOR = "#009E73"
MISSED_COLOR = "#AE3030"

# Data — realistic NBA half-court shot attempts
np.random.seed(42)

close_angles = np.random.uniform(0.15, np.pi - 0.15, 100)
close_dist = np.random.uniform(1.5, 8, 100)
mid_angles = np.random.uniform(0.2, np.pi - 0.2, 100)
mid_dist = np.random.uniform(8, 22, 100)
three_angles = np.random.uniform(0.35, np.pi - 0.35, 80)
three_dist = np.random.uniform(23.5, 27, 80)
ft_angles = np.random.uniform(np.pi / 2 - 0.08, np.pi / 2 + 0.08, 20)
ft_dist = np.full(20, 13.75) + np.random.normal(0, 0.3, 20)

shot_x = np.concatenate(
    [
        close_dist * np.cos(close_angles),
        mid_dist * np.cos(mid_angles),
        three_dist * np.cos(three_angles),
        ft_dist * np.cos(ft_angles),
    ]
)
shot_y = np.concatenate(
    [
        close_dist * np.sin(close_angles),
        mid_dist * np.sin(mid_angles),
        three_dist * np.sin(three_angles),
        ft_dist * np.sin(ft_angles),
    ]
)

shot_type = ["2-pointer"] * 200 + ["3-pointer"] * 80 + ["free-throw"] * 20
make_probs = np.concatenate([np.full(100, 0.55), np.full(100, 0.40), np.full(80, 0.35), np.full(20, 0.80)])
made = np.random.binomial(1, make_probs).astype(bool)

shots_df = pd.DataFrame(
    {
        "shot_x": np.clip(shot_x, -24.5, 24.5),
        "shot_y": np.clip(shot_y, -4, 40),
        "result": np.where(made, "Made", "Missed"),
        "shot_type": shot_type,
    }
)

# Court geometry (NBA half-court, basket at origin) — flat rows for Altair line mark
theta_ft = np.linspace(0, np.pi, 60)
theta_3 = np.linspace(np.arccos(22 / 23.75), np.pi - np.arccos(22 / 23.75), 100)
theta_ra = np.linspace(0, np.pi, 40)
theta_b = np.linspace(0, 2 * np.pi + 0.1, 40)
theta_cc = np.linspace(np.pi, 2 * np.pi, 40)
corner_y = np.sqrt(23.75**2 - 22**2)

segments = [
    ([-25, -25], [-5.25, 41.75], "sideline_l"),
    ([25, 25], [-5.25, 41.75], "sideline_r"),
    ([-25, 25], [-5.25, -5.25], "baseline"),
    ([-25, 25], [41.75, 41.75], "halfcourt"),
    ([-8, -8], [-5.25, 13.75], "paint_l"),
    ([8, 8], [-5.25, 13.75], "paint_r"),
    ([-8, 8], [13.75, 13.75], "ft_line"),
    (6 * np.cos(theta_ft), 13.75 + 6 * np.sin(theta_ft), "ft_circle"),
    ([-22, -22], [-5.25, corner_y], "corner3_l"),
    ([22, 22], [-5.25, corner_y], "corner3_r"),
    (23.75 * np.cos(theta_3), 23.75 * np.sin(theta_3), "three_arc"),
    (4 * np.cos(theta_ra), 4 * np.sin(theta_ra), "restricted"),
    (0.75 * np.cos(theta_b), 0.75 * np.sin(theta_b), "basket"),
    ([-3, 3], [-1.0, -1.0], "backboard"),
    (6 * np.cos(theta_cc), 41.75 + 6 * np.sin(theta_cc), "center_circle"),
]

court_rows = []
for xs, ys, seg_name in segments:
    for i, (xi, yi) in enumerate(zip(xs, ys, strict=True)):
        court_rows.append({"cx": float(xi), "cy": float(yi), "seg": seg_name, "ord": i})

court_df = pd.DataFrame(court_rows)

# Zone shooting percentages
paint_mask = (shots_df["shot_y"] < 13.75) & (shots_df["shot_x"].abs() < 8) & (shots_df["shot_type"] != "free-throw")
mid_mask = (shots_df["shot_type"] == "2-pointer") & ~((shots_df["shot_y"] < 13.75) & (shots_df["shot_x"].abs() < 8))
three_mask = shots_df["shot_type"] == "3-pointer"

paint_pct = int(100 * shots_df.loc[paint_mask, "result"].eq("Made").mean())
mid_pct = int(100 * shots_df.loc[mid_mask, "result"].eq("Made").mean())
three_pct = int(100 * shots_df.loc[three_mask, "result"].eq("Made").mean())
total_fg = int(100 * shots_df["result"].eq("Made").mean())

zone_df = pd.DataFrame(
    [
        {"label": f"Paint: {paint_pct}%", "zx": 0.0, "zy": 6.0},
        {"label": f"Mid-Range: {mid_pct}%", "zx": 0.0, "zy": 20.0},
        {"label": f"3PT: {three_pct}%", "zx": 0.0, "zy": 30.0},
    ]
)

# Shared scales — equal 52-unit domain on both axes for undistorted 1:1 court
x_scale = alt.Scale(domain=[-26, 26], nice=False)
y_scale = alt.Scale(domain=[-7, 45], nice=False)

TITLE = "scatter-shot-chart · python · altair · anyplot.ai"

# Court lines layer
court = (
    alt.Chart(court_df)
    .mark_line(strokeWidth=1.5, color=INK_SOFT)
    .encode(
        x=alt.X("cx:Q", scale=x_scale, axis=None),
        y=alt.Y("cy:Q", scale=y_scale, axis=None),
        detail="seg:N",
        order="ord:Q",
    )
)

# Shot markers layer
shots = (
    alt.Chart(shots_df)
    .mark_point(filled=True, size=55, opacity=0.6, strokeWidth=0.5, stroke=PAGE_BG)
    .encode(
        x=alt.X("shot_x:Q", scale=x_scale, axis=None),
        y=alt.Y("shot_y:Q", scale=y_scale, axis=None),
        color=alt.Color(
            "result:N",
            scale=alt.Scale(domain=["Made", "Missed"], range=[MADE_COLOR, MISSED_COLOR]),
            legend=alt.Legend(
                title="Shot Result", titleFontSize=14, labelFontSize=12, symbolSize=120, orient="top-right", offset=8
            ),
        ),
        shape=alt.Shape("result:N", scale=alt.Scale(domain=["Made", "Missed"], range=["circle", "cross"]), legend=None),
        tooltip=[
            alt.Tooltip("shot_type:N", title="Shot Type"),
            alt.Tooltip("result:N", title="Result"),
            alt.Tooltip("shot_x:Q", title="X (ft)", format=".1f"),
            alt.Tooltip("shot_y:Q", title="Y (ft)", format=".1f"),
        ],
    )
)

# Zone percentage labels
zones = (
    alt.Chart(zone_df)
    .mark_text(fontSize=13, fontWeight="bold", color=INK_MUTED, opacity=0.85)
    .encode(x=alt.X("zx:Q", scale=x_scale, axis=None), y=alt.Y("zy:Q", scale=y_scale, axis=None), text="label:N")
)

# Compose with theme-adaptive chrome
chart = (
    (court + shots + zones)
    .properties(
        width=500,
        height=500,
        background=PAGE_BG,
        title=alt.Title(
            TITLE,
            fontSize=16,
            color=INK,
            subtitle=f"NBA Player Shot Chart — 300 Attempts (FG {total_fg}%)",
            subtitleFontSize=12,
            subtitleColor=INK_SOFT,
            subtitlePadding=6,
        ),
    )
    .interactive()
    .configure_view(fill=PAGE_BG, strokeWidth=0)
    .configure_title(color=INK)
    .configure_legend(fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK)
)

# Save — pad to exact 2400×2400 (square, 1:1 court aspect ratio)
TW, TH = 2400, 2400
chart.save(f"plot-{THEME}.png", scale_factor=4.0)
_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 Basketball Shot Chart on anyplot.ai.

Other implementations