Skip to content

Commit 9396b44

Browse files
feat(altair): implement scatter-shot-chart (#5118)
## Implementation: `scatter-shot-chart` - altair Implements the **altair** version of `scatter-shot-chart`. **File:** `plots/scatter-shot-chart/implementations/altair.py` **Parent Issue:** #4416 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/23361192907)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent f610643 commit 9396b44

2 files changed

Lines changed: 414 additions & 0 deletions

File tree

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
""" pyplots.ai
2+
scatter-shot-chart: Basketball Shot Chart
3+
Library: altair 6.0.0 | Python 3.14.3
4+
Quality: 86/100 | Created: 2026-03-20
5+
"""
6+
7+
import altair as alt
8+
import numpy as np
9+
import pandas as pd
10+
11+
12+
# Data - Realistic basketball shot attempts
13+
np.random.seed(42)
14+
15+
close_angles = np.random.uniform(0.15, np.pi - 0.15, 100)
16+
close_dist = np.random.uniform(1.5, 8, 100)
17+
18+
mid_angles = np.random.uniform(0.2, np.pi - 0.2, 100)
19+
mid_dist = np.random.uniform(8, 22, 100)
20+
21+
three_angles = np.random.uniform(0.35, np.pi - 0.35, 80)
22+
three_dist = np.random.uniform(23.5, 27, 80)
23+
24+
ft_angles = np.random.uniform(np.pi / 2 - 0.08, np.pi / 2 + 0.08, 20)
25+
ft_dist = np.full(20, 13.75) + np.random.normal(0, 0.3, 20)
26+
27+
x = np.concatenate(
28+
[
29+
close_dist * np.cos(close_angles),
30+
mid_dist * np.cos(mid_angles),
31+
three_dist * np.cos(three_angles),
32+
ft_dist * np.cos(ft_angles),
33+
]
34+
)
35+
y = np.concatenate(
36+
[
37+
close_dist * np.sin(close_angles),
38+
mid_dist * np.sin(mid_angles),
39+
three_dist * np.sin(three_angles),
40+
ft_dist * np.sin(ft_angles),
41+
]
42+
)
43+
44+
shot_type = ["2-pointer"] * 200 + ["3-pointer"] * 80 + ["free-throw"] * 20
45+
make_probs = np.concatenate([np.full(100, 0.55), np.full(100, 0.40), np.full(80, 0.35), np.full(20, 0.80)])
46+
made = np.random.binomial(1, make_probs).astype(bool)
47+
48+
shots_df = pd.DataFrame(
49+
{
50+
"x": np.clip(x, -24.5, 24.5),
51+
"y": np.clip(y, -4, 40),
52+
"result": np.where(made, "Made", "Missed"),
53+
"shot_type": shot_type,
54+
}
55+
)
56+
57+
# Court geometry (NBA half-court, basket at origin) — flat data construction
58+
theta_ft = np.linspace(0, np.pi, 60)
59+
theta_3 = np.linspace(np.arccos(22 / 23.75), np.pi - np.arccos(22 / 23.75), 100)
60+
theta_ra = np.linspace(0, np.pi, 40)
61+
theta_b = np.linspace(0, 2 * np.pi + 0.1, 40)
62+
theta_cc = np.linspace(np.pi, 2 * np.pi, 40)
63+
corner_y = np.sqrt(23.75**2 - 22**2)
64+
65+
# Build all court segments as (xs_array, ys_array, segment_name)
66+
segments = [
67+
([-25, -25], [-5.25, 41.75], "sideline_l"),
68+
([25, 25], [-5.25, 41.75], "sideline_r"),
69+
([-25, 25], [-5.25, -5.25], "baseline"),
70+
([-25, 25], [41.75, 41.75], "halfcourt"),
71+
([-8, -8], [-5.25, 13.75], "paint_l"),
72+
([8, 8], [-5.25, 13.75], "paint_r"),
73+
([-8, 8], [13.75, 13.75], "ft_line"),
74+
(6 * np.cos(theta_ft), 13.75 + 6 * np.sin(theta_ft), "ft_circle"),
75+
([-22, -22], [-5.25, corner_y], "corner3_l"),
76+
([22, 22], [-5.25, corner_y], "corner3_r"),
77+
(23.75 * np.cos(theta_3), 23.75 * np.sin(theta_3), "three_arc"),
78+
(4 * np.cos(theta_ra), 4 * np.sin(theta_ra), "restricted"),
79+
(0.75 * np.cos(theta_b), 0.75 * np.sin(theta_b), "basket"),
80+
([-3, 3], [-1.0, -1.0], "backboard"),
81+
(6 * np.cos(theta_cc), 41.75 + 6 * np.sin(theta_cc), "center_circle"),
82+
]
83+
84+
court_lines = []
85+
for xs, ys, seg_name in segments:
86+
for i, (xi, yi) in enumerate(zip(xs, ys, strict=True)):
87+
court_lines.append({"cx": float(xi), "cy": float(yi), "seg": seg_name, "ord": i})
88+
89+
court_df = pd.DataFrame(court_lines)
90+
91+
# Zone annotations with shooting percentages
92+
paint_mask = (shots_df["y"] < 13.75) & (shots_df["x"].abs() < 8) & (shots_df["shot_type"] != "free-throw")
93+
mid_mask = (shots_df["shot_type"] == "2-pointer") & ~((shots_df["y"] < 13.75) & (shots_df["x"].abs() < 8))
94+
three_mask = shots_df["shot_type"] == "3-pointer"
95+
96+
paint_pct = int(100 * shots_df.loc[paint_mask, "result"].eq("Made").mean())
97+
mid_pct = int(100 * shots_df.loc[mid_mask, "result"].eq("Made").mean())
98+
three_pct = int(100 * shots_df.loc[three_mask, "result"].eq("Made").mean())
99+
total_fg = int(100 * shots_df["result"].eq("Made").mean())
100+
101+
zone_df = pd.DataFrame(
102+
[
103+
{"label": f"Paint: {paint_pct}%", "zx": 0, "zy": 6},
104+
{"label": f"Mid-Range: {mid_pct}%", "zx": 0, "zy": 20},
105+
{"label": f"3PT: {three_pct}%", "zx": 0, "zy": 30},
106+
]
107+
)
108+
109+
# Scales (equal domain range for 1:1 aspect ratio)
110+
x_scale = alt.Scale(domain=[-26, 26], nice=False)
111+
y_scale = alt.Scale(domain=[-7, 45], nice=False)
112+
113+
# Court lines layer
114+
court = (
115+
alt.Chart(court_df)
116+
.mark_line(strokeWidth=1.8, color="#888888")
117+
.encode(
118+
x=alt.X("cx:Q", scale=x_scale, axis=None),
119+
y=alt.Y("cy:Q", scale=y_scale, axis=None),
120+
detail="seg:N",
121+
order="ord:Q",
122+
)
123+
)
124+
125+
# Shot markers layer — size and opacity tuned for 300 points
126+
shots = (
127+
alt.Chart(shots_df)
128+
.mark_point(filled=True, size=50, opacity=0.55, strokeWidth=0.5, stroke="white")
129+
.encode(
130+
x=alt.X("x:Q", scale=x_scale),
131+
y=alt.Y("y:Q", scale=y_scale),
132+
color=alt.Color(
133+
"result:N",
134+
scale=alt.Scale(domain=["Made", "Missed"], range=["#306998", "#e67e22"]),
135+
legend=alt.Legend(
136+
title="Shot Result", titleFontSize=18, labelFontSize=16, symbolSize=150, orient="top-right", offset=10
137+
),
138+
),
139+
shape=alt.Shape("result:N", scale=alt.Scale(domain=["Made", "Missed"], range=["circle", "cross"]), legend=None),
140+
tooltip=[
141+
alt.Tooltip("shot_type:N", title="Shot Type"),
142+
alt.Tooltip("result:N", title="Result"),
143+
alt.Tooltip("x:Q", title="X (ft)", format=".1f"),
144+
alt.Tooltip("y:Q", title="Y (ft)", format=".1f"),
145+
],
146+
)
147+
)
148+
149+
# Zone annotation layer
150+
zones = (
151+
alt.Chart(zone_df)
152+
.mark_text(fontSize=15, fontWeight="bold", color="#555555", opacity=0.7)
153+
.encode(x=alt.X("zx:Q", scale=x_scale), y=alt.Y("zy:Q", scale=y_scale), text="label:N")
154+
)
155+
156+
# Compose
157+
chart = (
158+
(court + shots + zones)
159+
.properties(
160+
width=1200,
161+
height=1200,
162+
title=alt.Title(
163+
"scatter-shot-chart · altair · pyplots.ai",
164+
fontSize=28,
165+
color="#222222",
166+
subtitle=f"NBA Player Shot Chart — 300 Attempts (FG {total_fg}%)",
167+
subtitleFontSize=16,
168+
subtitleColor="#777777",
169+
subtitlePadding=6,
170+
),
171+
)
172+
.configure_view(strokeWidth=0)
173+
.interactive()
174+
)
175+
176+
# Save
177+
chart.save("plot.png", scale_factor=3.0)
178+
chart.save("plot.html")

0 commit comments

Comments
 (0)