Skip to content

Commit ecceaa7

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

2 files changed

Lines changed: 543 additions & 0 deletions

File tree

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
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

Comments
 (0)