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