|
| 1 | +""" pyplots.ai |
| 2 | +scatter-shot-chart: Basketball Shot Chart |
| 3 | +Library: plotly 6.6.0 | Python 3.14.3 |
| 4 | +Quality: 89/100 | Created: 2026-03-20 |
| 5 | +""" |
| 6 | + |
| 7 | +import numpy as np |
| 8 | +import plotly.graph_objects as go |
| 9 | + |
| 10 | + |
| 11 | +# Data |
| 12 | +np.random.seed(42) |
| 13 | +n_shots = 350 |
| 14 | + |
| 15 | +# Generate shot locations across the half-court |
| 16 | +# Court: 50 ft wide (-25 to 25), 47 ft deep (0 to 47) from baseline |
| 17 | +x = np.concatenate( |
| 18 | + [ |
| 19 | + np.random.normal(0, 4, 80), # Paint area shots |
| 20 | + np.random.normal(0, 8, 100), # Mid-range |
| 21 | + np.random.uniform(-22, 22, 50), # Corner threes and wings |
| 22 | + np.random.normal(0, 10, 70), # Top of key / three-point |
| 23 | + np.zeros(50), # Free throws |
| 24 | + ] |
| 25 | +) |
| 26 | +y = np.concatenate( |
| 27 | + [ |
| 28 | + np.random.uniform(0, 8, 80), # Paint area |
| 29 | + np.random.uniform(6, 18, 100), # Mid-range |
| 30 | + np.random.uniform(0, 10, 50), # Corner threes |
| 31 | + np.random.uniform(20, 28, 70), # Top of key / three-point |
| 32 | + np.full(50, 15.0) + np.random.normal(0, 0.3, 50), # Free throws |
| 33 | + ] |
| 34 | +) |
| 35 | + |
| 36 | +# Clip to court boundaries |
| 37 | +x = np.clip(x, -24.5, 24.5) |
| 38 | +y = np.clip(y, 0.5, 46) |
| 39 | + |
| 40 | +# Shot outcomes — closer shots have higher make percentage |
| 41 | +distance = np.sqrt(x**2 + y**2) |
| 42 | +make_prob = np.clip(0.65 - distance * 0.012, 0.25, 0.70) |
| 43 | +made = np.random.random(len(x)) < make_prob |
| 44 | + |
| 45 | +# Shot types based on distance from basket |
| 46 | +three_pt_distance = np.where(np.abs(x) >= 22, 22.0, 23.75) |
| 47 | +shot_type = np.where(y < 1.5, "free-throw", np.where(distance > three_pt_distance, "3-pointer", "2-pointer")) |
| 48 | +ft_mask = (np.abs(x) < 1) & (y > 14) & (y < 16) |
| 49 | +shot_type = np.where(ft_mask, "free-throw", shot_type) |
| 50 | + |
| 51 | +# Compute zone shooting percentages for storytelling |
| 52 | +paint_mask = (np.abs(x) < 8) & (y < 10) |
| 53 | +mid_mask = ~paint_mask & (distance <= three_pt_distance) & ~ft_mask |
| 54 | +three_mask = distance > three_pt_distance |
| 55 | +paint_pct = np.mean(made[paint_mask]) * 100 |
| 56 | +mid_pct = np.mean(made[mid_mask]) * 100 |
| 57 | +three_pct = np.mean(made[three_mask]) * 100 |
| 58 | +overall_pct = np.mean(made) * 100 |
| 59 | + |
| 60 | +# Court drawing shapes |
| 61 | +court_shapes = [] |
| 62 | + |
| 63 | +# Court shadow for subtle depth |
| 64 | +court_shapes.append( |
| 65 | + { |
| 66 | + "type": "rect", |
| 67 | + "x0": -24.6, |
| 68 | + "y0": -0.4, |
| 69 | + "x1": 25.4, |
| 70 | + "y1": 47.4, |
| 71 | + "line": {"width": 0}, |
| 72 | + "fillcolor": "rgba(80,60,40,0.08)", |
| 73 | + "layer": "below", |
| 74 | + } |
| 75 | +) |
| 76 | + |
| 77 | +# Subtle court floor — warm hardwood tone (layer below traces so shots are visible) |
| 78 | +court_shapes.append( |
| 79 | + { |
| 80 | + "type": "rect", |
| 81 | + "x0": -25, |
| 82 | + "y0": 0, |
| 83 | + "x1": 25, |
| 84 | + "y1": 47, |
| 85 | + "line": {"color": "#6B5B4F", "width": 2.5}, |
| 86 | + "fillcolor": "#FDF6EC", |
| 87 | + "layer": "below", |
| 88 | + } |
| 89 | +) |
| 90 | + |
| 91 | +# Paint / key area with subtle highlight |
| 92 | +court_shapes.append( |
| 93 | + { |
| 94 | + "type": "rect", |
| 95 | + "x0": -8, |
| 96 | + "y0": 0, |
| 97 | + "x1": 8, |
| 98 | + "y1": 19, |
| 99 | + "line": {"color": "#6B5B4F", "width": 2}, |
| 100 | + "fillcolor": "#F5EBD8", |
| 101 | + "layer": "below", |
| 102 | + } |
| 103 | +) |
| 104 | + |
| 105 | +# Free-throw circle (6 ft radius at y=19) |
| 106 | +theta_ft = np.linspace(0, np.pi, 100) |
| 107 | +ft_circle_x = 6 * np.cos(theta_ft) |
| 108 | +ft_circle_y = 19 + 6 * np.sin(theta_ft) |
| 109 | + |
| 110 | +# Restricted area arc (4 ft radius) |
| 111 | +theta_ra = np.linspace(0, np.pi, 100) |
| 112 | +ra_x = 4 * np.cos(theta_ra) |
| 113 | +ra_y = 4 * np.sin(theta_ra) |
| 114 | + |
| 115 | +# Three-point arc |
| 116 | +theta_3pt = np.linspace(np.arccos(22 / 23.75), np.pi - np.arccos(22 / 23.75), 200) |
| 117 | +three_x = 23.75 * np.cos(theta_3pt) |
| 118 | +three_y = 23.75 * np.sin(theta_3pt) |
| 119 | +corner_3_left_x = [-22, -22] |
| 120 | +corner_3_left_y = [0, 23.75 * np.sin(np.arccos(22 / 23.75))] |
| 121 | +corner_3_right_x = [22, 22] |
| 122 | +corner_3_right_y = [0, 23.75 * np.sin(np.arccos(22 / 23.75))] |
| 123 | + |
| 124 | +# Backboard |
| 125 | +court_shapes.append( |
| 126 | + { |
| 127 | + "type": "line", |
| 128 | + "x0": -3, |
| 129 | + "y0": -0.5, |
| 130 | + "x1": 3, |
| 131 | + "y1": -0.5, |
| 132 | + "line": {"color": "#6B5B4F", "width": 3}, |
| 133 | + "layer": "below", |
| 134 | + } |
| 135 | +) |
| 136 | + |
| 137 | +# Basket (rim) |
| 138 | +theta_rim = np.linspace(0, 2 * np.pi, 50) |
| 139 | +rim_x = 0.75 * np.cos(theta_rim) |
| 140 | +rim_y = 1.25 + 0.75 * np.sin(theta_rim) |
| 141 | + |
| 142 | +# Plot |
| 143 | +fig = go.Figure() |
| 144 | + |
| 145 | +# Court line style |
| 146 | +line_style = {"color": "#6B5B4F", "width": 2} |
| 147 | + |
| 148 | +# Three-point arc |
| 149 | +fig.add_trace( |
| 150 | + go.Scatter( |
| 151 | + x=np.concatenate([[-22], three_x[::-1], [22]]), |
| 152 | + y=np.concatenate([[0], three_y[::-1], [0]]), |
| 153 | + mode="lines", |
| 154 | + line={"color": "#6B5B4F", "width": 2.5}, |
| 155 | + showlegend=False, |
| 156 | + hoverinfo="skip", |
| 157 | + ) |
| 158 | +) |
| 159 | + |
| 160 | +# Corner three lines |
| 161 | +fig.add_trace( |
| 162 | + go.Scatter(x=corner_3_left_x, y=corner_3_left_y, mode="lines", line=line_style, showlegend=False, hoverinfo="skip") |
| 163 | +) |
| 164 | +fig.add_trace( |
| 165 | + go.Scatter( |
| 166 | + x=corner_3_right_x, y=corner_3_right_y, mode="lines", line=line_style, showlegend=False, hoverinfo="skip" |
| 167 | + ) |
| 168 | +) |
| 169 | + |
| 170 | +# Free-throw circle (top half) |
| 171 | +fig.add_trace( |
| 172 | + go.Scatter(x=ft_circle_x, y=ft_circle_y, mode="lines", line=line_style, showlegend=False, hoverinfo="skip") |
| 173 | +) |
| 174 | + |
| 175 | +# Free-throw circle (bottom half, dashed) |
| 176 | +theta_ft_bottom = np.linspace(np.pi, 2 * np.pi, 100) |
| 177 | +fig.add_trace( |
| 178 | + go.Scatter( |
| 179 | + x=6 * np.cos(theta_ft_bottom), |
| 180 | + y=19 + 6 * np.sin(theta_ft_bottom), |
| 181 | + mode="lines", |
| 182 | + line={"color": "#6B5B4F", "width": 2, "dash": "dash"}, |
| 183 | + showlegend=False, |
| 184 | + hoverinfo="skip", |
| 185 | + ) |
| 186 | +) |
| 187 | + |
| 188 | +# Restricted area arc |
| 189 | +fig.add_trace(go.Scatter(x=ra_x, y=ra_y, mode="lines", line=line_style, showlegend=False, hoverinfo="skip")) |
| 190 | + |
| 191 | +# Basket rim |
| 192 | +fig.add_trace( |
| 193 | + go.Scatter( |
| 194 | + x=rim_x, y=rim_y, mode="lines", line={"color": "#CC5500", "width": 2.5}, showlegend=False, hoverinfo="skip" |
| 195 | + ) |
| 196 | +) |
| 197 | + |
| 198 | +# Colorblind-safe palette: blue for made, orange for missed |
| 199 | +color_made = "#306998" |
| 200 | +color_missed = "#E8871E" |
| 201 | + |
| 202 | +# Marker sizes vary slightly by distance for visual depth |
| 203 | +marker_sizes = np.clip(14 - distance * 0.15, 8, 14) |
| 204 | + |
| 205 | +# Shot markers — missed shots first (underneath) |
| 206 | +missed_mask = ~made |
| 207 | +fig.add_trace( |
| 208 | + go.Scatter( |
| 209 | + x=x[missed_mask], |
| 210 | + y=y[missed_mask], |
| 211 | + mode="markers", |
| 212 | + marker={ |
| 213 | + "size": marker_sizes[missed_mask], |
| 214 | + "color": color_missed, |
| 215 | + "symbol": "x", |
| 216 | + "line": {"width": 1.5, "color": color_missed}, |
| 217 | + "opacity": 0.7, |
| 218 | + }, |
| 219 | + name="Missed", |
| 220 | + hovertemplate="x: %{x:.1f} ft<br>y: %{y:.1f} ft<br>Missed<extra></extra>", |
| 221 | + ) |
| 222 | +) |
| 223 | + |
| 224 | +# Made shots on top |
| 225 | +fig.add_trace( |
| 226 | + go.Scatter( |
| 227 | + x=x[made], |
| 228 | + y=y[made], |
| 229 | + mode="markers", |
| 230 | + marker={ |
| 231 | + "size": marker_sizes[made], |
| 232 | + "color": color_made, |
| 233 | + "symbol": "circle", |
| 234 | + "line": {"width": 1.2, "color": "white"}, |
| 235 | + "opacity": 0.8, |
| 236 | + }, |
| 237 | + name="Made", |
| 238 | + hovertemplate="x: %{x:.1f} ft<br>y: %{y:.1f} ft<br>Made<extra></extra>", |
| 239 | + ) |
| 240 | +) |
| 241 | + |
| 242 | +# Zone shooting percentage annotations for storytelling |
| 243 | +fig.add_annotation( |
| 244 | + x=0, |
| 245 | + y=9, |
| 246 | + text=f"Paint<br><b>{paint_pct:.0f}%</b>", |
| 247 | + showarrow=False, |
| 248 | + font={"size": 20, "color": "#3A3A3A", "family": "Arial Black, sans-serif"}, |
| 249 | + bgcolor="rgba(255,255,255,0.8)", |
| 250 | + borderpad=6, |
| 251 | + bordercolor="rgba(107,91,79,0.3)", |
| 252 | + borderwidth=1, |
| 253 | +) |
| 254 | +fig.add_annotation( |
| 255 | + x=18, |
| 256 | + y=16, |
| 257 | + text=f"Mid-range<br><b>{mid_pct:.0f}%</b>", |
| 258 | + showarrow=False, |
| 259 | + font={"size": 18, "color": "#3A3A3A", "family": "Arial Black, sans-serif"}, |
| 260 | + bgcolor="rgba(255,255,255,0.8)", |
| 261 | + borderpad=6, |
| 262 | + bordercolor="rgba(107,91,79,0.3)", |
| 263 | + borderwidth=1, |
| 264 | +) |
| 265 | +fig.add_annotation( |
| 266 | + x=0, |
| 267 | + y=35, |
| 268 | + text=f"3-Point<br><b>{three_pct:.0f}%</b>", |
| 269 | + showarrow=False, |
| 270 | + font={"size": 18, "color": "#3A3A3A", "family": "Arial Black, sans-serif"}, |
| 271 | + bgcolor="rgba(255,255,255,0.8)", |
| 272 | + borderpad=6, |
| 273 | + bordercolor="rgba(107,91,79,0.3)", |
| 274 | + borderwidth=1, |
| 275 | +) |
| 276 | + |
| 277 | +# Style |
| 278 | +subtitle = f"{int(np.sum(made))}/{len(made)} shots made ({overall_pct:.1f}% FG) · Paint {paint_pct:.0f}% · Mid {mid_pct:.0f}% · 3PT {three_pct:.0f}%" |
| 279 | +fig.update_layout( |
| 280 | + title={ |
| 281 | + "text": f"scatter-shot-chart · plotly · pyplots.ai<br><span style='font-size:20px;color:#777777'>{subtitle}</span>", |
| 282 | + "font": {"size": 30, "color": "#2A2A2A", "family": "Arial Black, sans-serif"}, |
| 283 | + "x": 0.5, |
| 284 | + "xanchor": "center", |
| 285 | + }, |
| 286 | + template="plotly_white", |
| 287 | + width=1200, |
| 288 | + height=1200, |
| 289 | + xaxis={ |
| 290 | + "range": [-28, 28], |
| 291 | + "showgrid": False, |
| 292 | + "zeroline": False, |
| 293 | + "showticklabels": False, |
| 294 | + "scaleanchor": "y", |
| 295 | + "scaleratio": 1, |
| 296 | + "fixedrange": True, |
| 297 | + }, |
| 298 | + yaxis={"range": [-2.5, 40], "showgrid": False, "zeroline": False, "showticklabels": False, "fixedrange": True}, |
| 299 | + plot_bgcolor="#FAFAFA", |
| 300 | + shapes=court_shapes, |
| 301 | + legend={ |
| 302 | + "font": {"size": 18}, |
| 303 | + "x": 0.85, |
| 304 | + "y": 0.98, |
| 305 | + "bgcolor": "rgba(255,255,255,0.9)", |
| 306 | + "bordercolor": "#CCCCCC", |
| 307 | + "borderwidth": 1, |
| 308 | + "itemsizing": "constant", |
| 309 | + }, |
| 310 | + margin={"l": 20, "r": 20, "t": 80, "b": 20}, |
| 311 | +) |
| 312 | + |
| 313 | +# Save |
| 314 | +fig.write_image("plot.png", width=1200, height=1200, scale=3) |
| 315 | +fig.write_html("plot.html", include_plotlyjs="cdn") |
0 commit comments