Skip to content

Commit 0a8cedf

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

2 files changed

Lines changed: 512 additions & 0 deletions

File tree

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
""" pyplots.ai
2+
scatter-shot-chart: Basketball Shot Chart
3+
Library: bokeh 3.9.0 | Python 3.14.3
4+
Quality: 91/100 | Created: 2026-03-20
5+
"""
6+
7+
import numpy as np
8+
from bokeh.io import export_png, save
9+
from bokeh.models import ColumnDataSource, HoverTool, Label, Legend, LegendItem, Range1d
10+
from bokeh.plotting import figure
11+
from bokeh.resources import CDN
12+
13+
14+
# Data
15+
np.random.seed(42)
16+
n_shots = 350
17+
18+
x = np.zeros(n_shots)
19+
y = np.zeros(n_shots)
20+
made = np.zeros(n_shots, dtype=bool)
21+
shot_type = []
22+
zone_label = []
23+
24+
for i in range(n_shots):
25+
zone = np.random.choice(["paint", "midrange", "three", "corner3", "ft"], p=[0.25, 0.20, 0.30, 0.10, 0.15])
26+
if zone == "paint":
27+
x[i] = np.random.uniform(-8, 8)
28+
y[i] = np.random.uniform(0, 12)
29+
made[i] = np.random.random() < 0.55
30+
shot_type.append("2-pointer")
31+
zone_label.append("Paint")
32+
elif zone == "midrange":
33+
x[i] = np.random.uniform(-16, 16)
34+
y[i] = np.random.uniform(5, 20)
35+
dist = np.sqrt(x[i] ** 2 + y[i] ** 2)
36+
while dist > 23.0 or dist < 5:
37+
x[i] = np.random.uniform(-16, 16)
38+
y[i] = np.random.uniform(5, 20)
39+
dist = np.sqrt(x[i] ** 2 + y[i] ** 2)
40+
made[i] = np.random.random() < 0.42
41+
shot_type.append("2-pointer")
42+
zone_label.append("Mid-Range")
43+
elif zone == "three":
44+
angle = np.random.uniform(0.25, np.pi - 0.25)
45+
r = np.random.uniform(24, 28)
46+
x[i] = r * np.cos(angle)
47+
y[i] = r * np.sin(angle)
48+
x[i] = np.clip(x[i], -24, 24)
49+
y[i] = np.clip(y[i], 10, 33)
50+
made[i] = np.random.random() < 0.36
51+
shot_type.append("3-pointer")
52+
zone_label.append("Three-Point")
53+
elif zone == "corner3":
54+
side = np.random.choice([-1, 1])
55+
x[i] = side * np.random.uniform(21.5, 23)
56+
y[i] = np.random.uniform(0, 10)
57+
made[i] = np.random.random() < 0.39
58+
shot_type.append("3-pointer")
59+
zone_label.append("Corner 3")
60+
else:
61+
x[i] = np.random.uniform(-1.5, 1.5)
62+
y[i] = np.random.uniform(13.5, 16.5)
63+
made[i] = np.random.random() < 0.78
64+
shot_type.append("free-throw")
65+
zone_label.append("Free Throw")
66+
67+
shot_type = np.array(shot_type)
68+
zone_label = np.array(zone_label)
69+
70+
# Zone efficiency stats for storytelling
71+
zones = ["Paint", "Mid-Range", "Three-Point", "Corner 3", "Free Throw"]
72+
zone_stats = {}
73+
for z in zones:
74+
mask = zone_label == z
75+
z_made = int(np.sum(made[mask]))
76+
z_total = int(np.sum(mask))
77+
z_pct = z_made / z_total * 100 if z_total > 0 else 0
78+
zone_stats[z] = (z_made, z_total, z_pct)
79+
80+
# Plot — 1:1 aspect ratio for undistorted court
81+
p = figure(
82+
width=3600,
83+
height=3600,
84+
title="scatter-shot-chart · bokeh · pyplots.ai",
85+
x_range=Range1d(-27, 27),
86+
y_range=Range1d(-3, 33),
87+
toolbar_location=None,
88+
match_aspect=True,
89+
)
90+
91+
# Court floor
92+
p.rect(x=0, y=16.5, width=54, height=37, fill_color="#F5F0E8", line_color=None)
93+
94+
# Baseline and sidelines (half-court)
95+
p.line([-25, 25], [0, 0], line_color="#888888", line_width=4)
96+
p.line([-25, -25], [0, 35], line_color="#888888", line_width=3)
97+
p.line([25, 25], [0, 35], line_color="#888888", line_width=3)
98+
99+
# Paint / key area (16 ft wide, 19 ft from baseline)
100+
p.line([-8, -8, 8, 8], [0, 19, 19, 0], line_color="#888888", line_width=3)
101+
102+
# Free-throw circle (top half solid, bottom half dashed)
103+
theta_top = np.linspace(0, np.pi, 100)
104+
theta_bot = np.linspace(np.pi, 2 * np.pi, 100)
105+
p.line(6 * np.cos(theta_top), 19 + 6 * np.sin(theta_top), line_color="#888888", line_width=3)
106+
p.line(6 * np.cos(theta_bot), 19 + 6 * np.sin(theta_bot), line_color="#888888", line_width=2, line_dash="dashed")
107+
108+
# Restricted area arc (4 ft radius from basket center)
109+
theta_ra = np.linspace(0, np.pi, 100)
110+
p.line(4 * np.cos(theta_ra), 4 * np.sin(theta_ra), line_color="#888888", line_width=2)
111+
112+
# Three-point arc (23.75 ft at top, 22 ft corners)
113+
theta_3pt = np.linspace(np.arccos(22.0 / 23.75), np.pi - np.arccos(22.0 / 23.75), 200)
114+
p.line(23.75 * np.cos(theta_3pt), 23.75 * np.sin(theta_3pt), line_color="#888888", line_width=3)
115+
116+
# Corner three-point lines (22 ft from basket, straight down to baseline)
117+
corner_y = 23.75 * np.sin(np.arccos(22.0 / 23.75))
118+
p.line([-22, -22], [0, corner_y], line_color="#888888", line_width=3)
119+
p.line([22, 22], [0, corner_y], line_color="#888888", line_width=3)
120+
121+
# Basket (hoop at center of rim, ~1.5 ft from backboard)
122+
hoop_theta = np.linspace(0, 2 * np.pi, 50)
123+
p.line(0.75 * np.cos(hoop_theta), 0.75 * np.sin(hoop_theta) + 1.5, line_color="#C44E2B", line_width=5)
124+
125+
# Backboard
126+
p.line([-3, 3], [0, 0], line_color="#555555", line_width=6)
127+
128+
# Shot markers — colorblind-safe: blue for made, orange for missed
129+
made_mask = made
130+
missed_mask = ~made
131+
132+
result_label = np.where(made, "Made", "Missed")
133+
distance = np.round(np.sqrt(x**2 + y**2), 1)
134+
135+
source_made = ColumnDataSource(
136+
data={
137+
"x": x[made_mask],
138+
"y": y[made_mask],
139+
"result": result_label[made_mask],
140+
"zone": zone_label[made_mask],
141+
"shot_type": shot_type[made_mask],
142+
"distance": distance[made_mask],
143+
}
144+
)
145+
source_missed = ColumnDataSource(
146+
data={
147+
"x": x[missed_mask],
148+
"y": y[missed_mask],
149+
"result": result_label[missed_mask],
150+
"zone": zone_label[missed_mask],
151+
"shot_type": shot_type[missed_mask],
152+
"distance": distance[missed_mask],
153+
}
154+
)
155+
156+
r_made = p.scatter(
157+
x="x",
158+
y="y",
159+
source=source_made,
160+
size=20,
161+
fill_color="#2171B5",
162+
fill_alpha=0.5,
163+
line_color="white",
164+
line_width=1.5,
165+
marker="circle",
166+
)
167+
168+
r_missed = p.scatter(
169+
x="x",
170+
y="y",
171+
source=source_missed,
172+
size=18,
173+
fill_color=None,
174+
fill_alpha=0,
175+
line_color="#E6550D",
176+
line_width=3.5,
177+
marker="x",
178+
)
179+
180+
# HoverTool — Bokeh's signature interactive feature
181+
hover = HoverTool(
182+
renderers=[r_made, r_missed],
183+
tooltips=[("Result", "@result"), ("Zone", "@zone"), ("Shot Type", "@shot_type"), ("Distance", "@distance ft")],
184+
point_policy="snap_to_data",
185+
)
186+
p.add_tools(hover)
187+
188+
# Legend
189+
n_made = int(np.sum(made))
190+
n_missed = int(np.sum(~made))
191+
legend = Legend(
192+
items=[
193+
LegendItem(label=f"Made ({n_made})", renderers=[r_made]),
194+
LegendItem(label=f"Missed ({n_missed})", renderers=[r_missed]),
195+
],
196+
location="top_center",
197+
orientation="horizontal",
198+
)
199+
200+
p.add_layout(legend, "above")
201+
p.legend.label_text_font_size = "28pt"
202+
p.legend.label_text_color = "#333333"
203+
p.legend.glyph_width = 40
204+
p.legend.glyph_height = 40
205+
p.legend.spacing = 50
206+
p.legend.padding = 20
207+
p.legend.background_fill_alpha = 0.0
208+
p.legend.border_line_color = None
209+
210+
# FG% summary
211+
fg_pct = n_made / n_shots * 100
212+
p.add_layout(
213+
Label(
214+
x=0,
215+
y=32,
216+
text=f"FG: {fg_pct:.1f}% · {n_shots} attempts",
217+
text_font_size="26pt",
218+
text_color="#666666",
219+
text_align="center",
220+
text_font_style="bold",
221+
)
222+
)
223+
224+
# Zone efficiency breakdown — data storytelling
225+
zone_positions = {
226+
"Paint": [(0, 6)],
227+
"Mid-Range": [(15, 14)],
228+
"Three-Point": [(0, 29)],
229+
"Corner 3": [(-21, 5), (21, 5)],
230+
"Free Throw": [(-12, 17)],
231+
}
232+
for z, positions in zone_positions.items():
233+
z_made, z_total, z_pct = zone_stats[z]
234+
for zx, zy in positions:
235+
p.add_layout(
236+
Label(
237+
x=zx,
238+
y=zy,
239+
text=f"{z_pct:.0f}%",
240+
text_font_size="22pt",
241+
text_color="#333333",
242+
text_align="center",
243+
text_font_style="bold",
244+
background_fill_color="#F5F0E8",
245+
background_fill_alpha=0.9,
246+
)
247+
)
248+
p.add_layout(
249+
Label(
250+
x=zx,
251+
y=zy - 1.8,
252+
text=f"{z_made}/{z_total}",
253+
text_font_size="18pt",
254+
text_color="#777777",
255+
text_align="center",
256+
background_fill_color="#F5F0E8",
257+
background_fill_alpha=0.9,
258+
)
259+
)
260+
261+
# Style
262+
p.title.text_font_size = "40pt"
263+
p.title.text_color = "#222222"
264+
p.title.text_font_style = "bold"
265+
p.title.align = "center"
266+
267+
p.xaxis.visible = False
268+
p.yaxis.visible = False
269+
p.xgrid.grid_line_color = None
270+
p.ygrid.grid_line_color = None
271+
272+
p.background_fill_color = "#F5F0E8"
273+
p.border_fill_color = "#FAFAFA"
274+
p.outline_line_color = None
275+
276+
# Save
277+
export_png(p, filename="plot.png")
278+
save(p, filename="plot.html", resources=CDN, title="scatter-shot-chart · bokeh · pyplots.ai")

0 commit comments

Comments
 (0)