Skip to content

Commit f610643

Browse files
feat(matplotlib): implement scatter-shot-chart (#5109)
## Implementation: `scatter-shot-chart` - matplotlib Implements the **matplotlib** version of `scatter-shot-chart`. **File:** `plots/scatter-shot-chart/implementations/matplotlib.py` **Parent Issue:** #4416 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/23361192893)* --------- 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 ecceaa7 commit f610643

2 files changed

Lines changed: 477 additions & 0 deletions

File tree

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
""" pyplots.ai
2+
scatter-shot-chart: Basketball Shot Chart
3+
Library: matplotlib 3.10.8 | Python 3.14.3
4+
Quality: 92/100 | Created: 2026-03-20
5+
"""
6+
7+
import matplotlib.patches as patches
8+
import matplotlib.patheffects as pe
9+
import matplotlib.pyplot as plt
10+
import numpy as np
11+
from matplotlib.colors import LinearSegmentedColormap
12+
from matplotlib.path import Path
13+
14+
15+
# Data
16+
np.random.seed(42)
17+
18+
n_ft = 25
19+
n_field = 325
20+
n_shots = n_field + n_ft
21+
22+
# Field goal shot locations in feet relative to basket center at (0, 0)
23+
x_field = np.concatenate(
24+
[
25+
np.random.normal(0, 3, 55), # paint area shots
26+
np.random.normal(0, 1.5, 25), # close to basket
27+
np.random.uniform(-8, 8, 50), # mid-range middle
28+
np.random.normal(-15, 3, 35), # left wing mid-range
29+
np.random.normal(15, 3, 35), # right wing mid-range
30+
np.random.normal(-22, 1.5, 25), # left corner three
31+
np.random.normal(22, 1.5, 25), # right corner three
32+
np.random.normal(0, 8, 40), # top of arc three
33+
np.random.normal(-12, 4, 18), # left wing three
34+
np.random.normal(12, 4, 17), # right wing three
35+
]
36+
)
37+
38+
y_field = np.concatenate(
39+
[
40+
np.random.uniform(0, 12, 55), # paint
41+
np.random.uniform(0, 4, 25), # close
42+
np.random.uniform(10, 18, 50), # mid-range
43+
np.random.uniform(5, 15, 35), # left wing mid
44+
np.random.uniform(5, 15, 35), # right wing mid
45+
np.random.uniform(0, 8, 25), # left corner
46+
np.random.uniform(0, 8, 25), # right corner
47+
np.random.uniform(22, 30, 40), # top of arc
48+
np.random.uniform(15, 25, 18), # left wing three
49+
np.random.uniform(15, 25, 17), # right wing three
50+
]
51+
)
52+
53+
# Free-throw shots clustered at the free-throw line (15 ft from backboard)
54+
x_ft = np.random.normal(0, 0.8, n_ft)
55+
y_ft = np.random.normal(14.0, 0.6, n_ft)
56+
57+
x = np.clip(np.concatenate([x_field, x_ft]), -24.5, 24.5)
58+
y = np.clip(np.concatenate([y_field, y_ft]), 0, 40)
59+
60+
# Shot outcome — closer shots have higher make rate; free throws ~75%
61+
distance = np.sqrt(x**2 + y**2)
62+
make_prob = np.clip(0.65 - distance * 0.012, 0.25, 0.70)
63+
# Free throws get a fixed ~75% make rate
64+
make_prob[-n_ft:] = 0.75
65+
made = np.random.random(n_shots) < make_prob
66+
67+
# Shot type based on distance from basket and free-throw designation
68+
three_pt_dist = np.where(np.abs(x) >= 22, 22.0, 23.75)
69+
shot_type = np.array(
70+
["3-pointer" if d >= t else "2-pointer" for d, t in zip(distance, three_pt_dist, strict=False)], dtype=object
71+
)
72+
shot_type[-n_ft:] = "free-throw"
73+
74+
# Plot
75+
fig, ax = plt.subplots(figsize=(12, 12))
76+
fig.set_facecolor("#1a1a2e")
77+
ax.set_facecolor("#2b2b40")
78+
79+
court_color = "#8899aa"
80+
lw = 2.0
81+
82+
# Court geometry using PatchCollection for batch rendering
83+
court_patches = [
84+
patches.Rectangle((-25, -5.25), 50, 47, linewidth=lw, edgecolor=court_color, facecolor="none"),
85+
patches.Circle((0, 0), 0.75, linewidth=lw, edgecolor="#ff6600", facecolor="none"),
86+
patches.Rectangle((-8, -5.25), 16, 19.25, linewidth=lw, edgecolor=court_color, facecolor="none"),
87+
patches.Arc((0, 14.0), 12, 12, angle=0, theta1=0, theta2=180, linewidth=lw, edgecolor=court_color),
88+
patches.Arc(
89+
(0, 14.0), 12, 12, angle=0, theta1=180, theta2=360, linewidth=lw, edgecolor=court_color, linestyle="--"
90+
),
91+
patches.Arc((0, 0), 8, 8, angle=0, theta1=0, theta2=180, linewidth=lw, edgecolor=court_color),
92+
patches.Arc((0, 41.75), 12, 12, angle=0, theta1=180, theta2=360, linewidth=lw, edgecolor=court_color),
93+
]
94+
for p in court_patches:
95+
ax.add_patch(p)
96+
97+
# Backboard
98+
ax.plot([-3, 3], [-1.0, -1.0], color=court_color, linewidth=3)
99+
100+
# Three-point line corners and arc
101+
corner_y = 8.75
102+
ax.plot([-22, -22], [-5.25, corner_y], color=court_color, linewidth=lw)
103+
ax.plot([22, 22], [-5.25, corner_y], color=court_color, linewidth=lw)
104+
105+
three_arc_angle = np.degrees(np.arccos(22.0 / 23.75))
106+
ax.add_patch(
107+
patches.Arc(
108+
(0, 0),
109+
47.5,
110+
47.5,
111+
angle=90,
112+
theta1=-90 + three_arc_angle,
113+
theta2=90 - three_arc_angle,
114+
linewidth=lw,
115+
edgecolor=court_color,
116+
)
117+
)
118+
119+
# Half-court line
120+
ax.plot([-25, 25], [41.75, 41.75], color=court_color, linewidth=lw)
121+
122+
# Subtle hexbin underlay showing shooting efficiency zones
123+
zone_cmap = LinearSegmentedColormap.from_list("efficiency", ["#e76f51", "#3d3d55", "#2a9d8f"])
124+
hb = ax.hexbin(
125+
x,
126+
y,
127+
C=made.astype(float),
128+
gridsize=15,
129+
cmap=zone_cmap,
130+
reduce_C_function=np.mean,
131+
alpha=0.12,
132+
extent=[-25, 25, -5, 42],
133+
mincnt=2,
134+
zorder=2,
135+
linewidths=0,
136+
)
137+
138+
# Custom marker path for made shots (diamond shape — distinctive from default circle)
139+
diamond_verts = [(-0.5, 0), (0, 0.7), (0.5, 0), (0, -0.7), (-0.5, 0)]
140+
diamond_codes = [Path.MOVETO, Path.LINETO, Path.LINETO, Path.LINETO, Path.CLOSEPOLY]
141+
diamond_marker = Path(diamond_verts, diamond_codes)
142+
143+
# Shot markers — colorblind-safe palette, sized for 350-point density
144+
c_made = "#2a9d8f"
145+
c_missed = "#e76f51"
146+
c_ft = "#f4a261"
147+
148+
field_made = made & (shot_type != "free-throw")
149+
field_missed = ~made & (shot_type != "free-throw")
150+
ft_made = made & (shot_type == "free-throw")
151+
ft_missed = ~made & (shot_type == "free-throw")
152+
153+
ax.scatter(
154+
x[field_missed], y[field_missed], s=45, marker="x", c=c_missed, alpha=0.45, linewidths=1.5, zorder=4, label="Missed"
155+
)
156+
ax.scatter(
157+
x[field_made],
158+
y[field_made],
159+
s=50,
160+
marker=diamond_marker,
161+
c=c_made,
162+
alpha=0.5,
163+
edgecolors="white",
164+
linewidth=0.4,
165+
zorder=5,
166+
label="Made",
167+
)
168+
# Free-throw shots with circle marker for visual distinction
169+
ax.scatter(x[ft_missed], y[ft_missed], s=40, marker="x", c=c_missed, alpha=0.5, linewidths=1.5, zorder=4)
170+
ax.scatter(
171+
x[ft_made],
172+
y[ft_made],
173+
s=45,
174+
marker="o",
175+
c=c_ft,
176+
alpha=0.6,
177+
edgecolors="white",
178+
linewidth=0.5,
179+
zorder=5,
180+
label="Free Throw",
181+
)
182+
183+
# Style
184+
ax.set_xlim(-27, 27)
185+
ax.set_ylim(-7, 44)
186+
ax.set_aspect("equal")
187+
ax.axis("off")
188+
189+
ax.set_title(
190+
"scatter-shot-chart · matplotlib · pyplots.ai",
191+
fontsize=24,
192+
fontweight="medium",
193+
color="#e0e0e0",
194+
pad=15,
195+
path_effects=[pe.withStroke(linewidth=3, foreground="#1a1a2e")],
196+
)
197+
198+
# Legend
199+
legend = ax.legend(
200+
loc="lower center",
201+
ncol=3,
202+
fontsize=18,
203+
framealpha=0.7,
204+
facecolor="#1a1a2e",
205+
edgecolor="#444444",
206+
labelcolor="#e0e0e0",
207+
bbox_to_anchor=(0.5, -0.03),
208+
markerscale=1.8,
209+
handletextpad=0.8,
210+
)
211+
212+
# Shooting summary with zone breakdown
213+
total = n_shots
214+
makes = int(made.sum())
215+
fg_pct = makes / total * 100
216+
twos = shot_type == "2-pointer"
217+
threes = shot_type == "3-pointer"
218+
fts = shot_type == "free-throw"
219+
fg2 = made[twos].sum() / twos.sum() * 100 if twos.sum() > 0 else 0
220+
fg3 = made[threes].sum() / threes.sum() * 100 if threes.sum() > 0 else 0
221+
ft_pct = made[fts].sum() / fts.sum() * 100 if fts.sum() > 0 else 0
222+
223+
summary = f"FG: {makes}/{total} ({fg_pct:.1f}%) | 2PT: {fg2:.0f}% | 3PT: {fg3:.0f}% | FT: {ft_pct:.0f}%"
224+
ax.text(
225+
0,
226+
43.5,
227+
summary,
228+
fontsize=16,
229+
color="#cccccc",
230+
ha="center",
231+
va="top",
232+
path_effects=[pe.withStroke(linewidth=2, foreground="#1a1a2e")],
233+
)
234+
235+
plt.tight_layout()
236+
plt.savefig("plot.png", dpi=300, bbox_inches="tight", facecolor=fig.get_facecolor())

0 commit comments

Comments
 (0)