|
| 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