Skip to content

Commit d828fc0

Browse files
feat(bokeh): implement scatter-pitch-events (#5094)
## Implementation: `scatter-pitch-events` - bokeh Implements the **bokeh** version of `scatter-pitch-events`. **File:** `plots/scatter-pitch-events/implementations/bokeh.py` **Parent Issue:** #4417 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/23342848863)* --------- 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 d8bf434 commit d828fc0

2 files changed

Lines changed: 484 additions & 0 deletions

File tree

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
""" pyplots.ai
2+
scatter-pitch-events: Soccer Pitch Event Map
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+
import pandas as pd
9+
from bokeh.io import export_png, save
10+
from bokeh.models import Arrow, ColumnDataSource, Label, NormalHead, Range1d
11+
from bokeh.plotting import figure
12+
from bokeh.resources import CDN
13+
14+
15+
# Data
16+
np.random.seed(42)
17+
n_events = 120
18+
19+
event_types = np.random.choice(["pass", "shot", "tackle", "interception"], size=n_events, p=[0.45, 0.15, 0.22, 0.18])
20+
21+
x_start = np.zeros(n_events)
22+
y_start = np.zeros(n_events)
23+
x_end = np.zeros(n_events)
24+
y_end = np.zeros(n_events)
25+
outcomes = []
26+
27+
for i, etype in enumerate(event_types):
28+
if etype == "pass":
29+
x_start[i] = np.random.uniform(10, 90)
30+
y_start[i] = np.random.uniform(5, 63)
31+
angle = np.random.uniform(-np.pi / 2, np.pi / 2)
32+
dist = np.random.uniform(5, 40)
33+
x_end[i] = np.clip(x_start[i] + dist * np.cos(angle), 0, 105)
34+
y_end[i] = np.clip(y_start[i] + dist * np.sin(angle), 0, 68)
35+
outcomes.append(np.random.choice(["successful", "unsuccessful"], p=[0.78, 0.22]))
36+
elif etype == "shot":
37+
x_start[i] = np.random.uniform(70, 100)
38+
y_start[i] = np.random.uniform(15, 53)
39+
x_end[i] = 105
40+
y_end[i] = np.random.uniform(28, 40)
41+
outcomes.append(np.random.choice(["successful", "unsuccessful"], p=[0.30, 0.70]))
42+
elif etype == "tackle":
43+
x_start[i] = np.random.uniform(15, 75)
44+
y_start[i] = np.random.uniform(5, 63)
45+
x_end[i] = x_start[i]
46+
y_end[i] = y_start[i]
47+
outcomes.append(np.random.choice(["successful", "unsuccessful"], p=[0.65, 0.35]))
48+
else:
49+
x_start[i] = np.random.uniform(20, 80)
50+
y_start[i] = np.random.uniform(5, 63)
51+
x_end[i] = x_start[i]
52+
y_end[i] = y_start[i]
53+
outcomes.append(np.random.choice(["successful", "unsuccessful"], p=[0.72, 0.28]))
54+
55+
outcomes = np.array(outcomes)
56+
57+
df = pd.DataFrame(
58+
{"x": x_start, "y": y_start, "x_end": x_end, "y_end": y_end, "event_type": event_types, "outcome": outcomes}
59+
)
60+
61+
# Colorblind-safe palette: blue, red, gold, purple (maximally distinct)
62+
event_colors = {"pass": "#306998", "shot": "#E63946", "tackle": "#E6A817", "interception": "#7B2D8E"}
63+
event_markers = {"pass": "circle", "shot": "star", "tackle": "triangle", "interception": "diamond"}
64+
65+
# Visual hierarchy: shots are largest (focal point), others smaller
66+
event_sizes = {"pass": 18, "shot": 30, "tackle": 20, "interception": 22}
67+
68+
# Plot
69+
p = figure(
70+
width=4800,
71+
height=2700,
72+
title="scatter-pitch-events · bokeh · pyplots.ai",
73+
x_range=Range1d(-6, 111),
74+
y_range=Range1d(-16, 74),
75+
toolbar_location=None,
76+
match_aspect=True,
77+
)
78+
79+
# Pitch background
80+
p.rect(x=52.5, y=34, width=105, height=68, fill_color="#4a9e50", fill_alpha=0.15, line_color=None)
81+
82+
# Subtle pitch stripes for visual texture (alternating mow pattern)
83+
for stripe_x in range(0, 105, 10):
84+
alpha = 0.04 if (stripe_x // 10) % 2 == 0 else 0.0
85+
p.rect(x=stripe_x + 5, y=34, width=10, height=68, fill_color="#2E7D32", fill_alpha=alpha, line_color=None)
86+
87+
# Danger zone gradient in attacking third — storytelling emphasis
88+
p.rect(x=96, y=34, width=18, height=68, fill_color="#E63946", fill_alpha=0.06, line_color=None)
89+
p.rect(x=100, y=34, width=10, height=68, fill_color="#E63946", fill_alpha=0.04, line_color=None)
90+
91+
# Pitch outline
92+
p.line([0, 105, 105, 0, 0], [0, 0, 68, 68, 0], line_color="#2E7D32", line_width=4)
93+
94+
# Halfway line
95+
p.line([52.5, 52.5], [0, 68], line_color="#2E7D32", line_width=3)
96+
97+
# Center circle
98+
theta = np.linspace(0, 2 * np.pi, 100)
99+
p.line(52.5 + 9.15 * np.cos(theta), 34 + 9.15 * np.sin(theta), line_color="#2E7D32", line_width=3)
100+
p.scatter([52.5], [34], size=10, color="#2E7D32")
101+
102+
# Left penalty area
103+
p.line([0, 16.5, 16.5, 0], [13.85, 13.85, 54.15, 54.15], line_color="#2E7D32", line_width=3)
104+
105+
# Right penalty area
106+
p.line([105, 88.5, 88.5, 105], [13.85, 13.85, 54.15, 54.15], line_color="#2E7D32", line_width=3)
107+
108+
# Left goal area
109+
p.line([0, 5.5, 5.5, 0], [24.85, 24.85, 43.15, 43.15], line_color="#2E7D32", line_width=3)
110+
111+
# Right goal area
112+
p.line([105, 99.5, 99.5, 105], [24.85, 24.85, 43.15, 43.15], line_color="#2E7D32", line_width=3)
113+
114+
# Penalty spots
115+
p.scatter([11, 94], [34, 34], size=8, color="#2E7D32")
116+
117+
# Penalty arcs
118+
arc_theta = np.linspace(-0.93, 0.93, 50)
119+
p.line(11 + 9.15 * np.cos(arc_theta), 34 + 9.15 * np.sin(arc_theta), line_color="#2E7D32", line_width=3)
120+
p.line(94 - 9.15 * np.cos(arc_theta), 34 + 9.15 * np.sin(arc_theta), line_color="#2E7D32", line_width=3)
121+
122+
# Corner arcs
123+
for cx, cy, a0, a1 in [
124+
(0, 0, 0, np.pi / 2),
125+
(105, 0, np.pi / 2, np.pi),
126+
(105, 68, np.pi, 3 * np.pi / 2),
127+
(0, 68, 3 * np.pi / 2, 2 * np.pi),
128+
]:
129+
ca = np.linspace(a0, a1, 25)
130+
p.line(cx + 1 * np.cos(ca), cy + 1 * np.sin(ca), line_color="#2E7D32", line_width=3)
131+
132+
# Goal posts
133+
p.line([-1.5, 0], [30.34, 30.34], line_color="#555555", line_width=6)
134+
p.line([-1.5, 0], [37.66, 37.66], line_color="#555555", line_width=6)
135+
p.line([-1.5, -1.5], [30.34, 37.66], line_color="#555555", line_width=6)
136+
p.line([105, 106.5], [30.34, 30.34], line_color="#555555", line_width=6)
137+
p.line([105, 106.5], [37.66, 37.66], line_color="#555555", line_width=6)
138+
p.line([106.5, 106.5], [30.34, 37.66], line_color="#555555", line_width=6)
139+
140+
# Directional arrows for passes and shots
141+
arrow_data = df[df["event_type"].isin(["pass", "shot"])]
142+
for _, row in arrow_data.iterrows():
143+
color = event_colors[row["event_type"]]
144+
alpha = 0.55 if row["outcome"] == "successful" else 0.25
145+
lw = 2.5 if row["event_type"] == "shot" else 1.8
146+
head_size = 14 if row["event_type"] == "shot" else 10
147+
p.add_layout(
148+
Arrow(
149+
end=NormalHead(size=head_size, fill_color=color, fill_alpha=alpha, line_color=color, line_alpha=alpha),
150+
x_start=row["x"],
151+
y_start=row["y"],
152+
x_end=row["x_end"],
153+
y_end=row["y_end"],
154+
line_color=color,
155+
line_alpha=alpha,
156+
line_width=lw,
157+
)
158+
)
159+
160+
# Event markers — shots emphasized as focal point
161+
for etype in ["pass", "tackle", "interception", "shot"]:
162+
for outcome in ["successful", "unsuccessful"]:
163+
mask = (df["event_type"] == etype) & (df["outcome"] == outcome)
164+
subset = df[mask]
165+
if len(subset) == 0:
166+
continue
167+
alpha = 0.9 if outcome == "successful" else 0.45
168+
fill = event_colors[etype] if outcome == "successful" else "white"
169+
line_w = 4 if etype == "shot" else 2.5
170+
source = ColumnDataSource(data={"x": subset["x"].values, "y": subset["y"].values})
171+
p.scatter(
172+
x="x",
173+
y="y",
174+
source=source,
175+
marker=event_markers[etype],
176+
size=event_sizes[etype],
177+
fill_color=fill,
178+
fill_alpha=alpha,
179+
line_color=event_colors[etype],
180+
line_width=line_w,
181+
line_alpha=0.95,
182+
legend_label=f"{etype.capitalize()} ({outcome})",
183+
)
184+
185+
# Storytelling annotation — highlight the danger zone
186+
shot_data = df[df["event_type"] == "shot"]
187+
n_shots = len(shot_data)
188+
n_on_target = len(shot_data[shot_data["outcome"] == "successful"])
189+
p.add_layout(
190+
Label(
191+
x=96,
192+
y=66,
193+
text=f"{n_shots} shots · {n_on_target} on target",
194+
text_font_size="22pt",
195+
text_color="#B71C1C",
196+
text_font_style="bold",
197+
text_alpha=0.8,
198+
)
199+
)
200+
201+
# Legend — positioned below pitch
202+
p.legend.location = "bottom_center"
203+
p.legend.orientation = "horizontal"
204+
p.legend.label_text_font_size = "22pt"
205+
p.legend.label_text_color = "#333333"
206+
p.legend.glyph_width = 32
207+
p.legend.glyph_height = 32
208+
p.legend.spacing = 30
209+
p.legend.padding = 15
210+
p.legend.background_fill_alpha = 0.92
211+
p.legend.background_fill_color = "white"
212+
p.legend.border_line_color = "#CCCCCC"
213+
p.legend.border_line_width = 2
214+
p.legend.ncols = 4
215+
p.legend.click_policy = "hide"
216+
217+
# Style
218+
p.title.text_font_size = "52pt"
219+
p.title.text_color = "#222222"
220+
p.title.text_font_style = "bold"
221+
222+
p.xaxis.axis_label = "Pitch Length (m)"
223+
p.yaxis.axis_label = "Pitch Width (m)"
224+
p.xaxis.axis_label_text_font_size = "36pt"
225+
p.yaxis.axis_label_text_font_size = "36pt"
226+
p.xaxis.major_label_text_font_size = "26pt"
227+
p.yaxis.major_label_text_font_size = "26pt"
228+
p.xaxis.axis_label_text_color = "#444444"
229+
p.yaxis.axis_label_text_color = "#444444"
230+
p.xaxis.major_label_text_color = "#555555"
231+
p.yaxis.major_label_text_color = "#555555"
232+
233+
p.xaxis.axis_line_color = None
234+
p.yaxis.axis_line_color = None
235+
p.xaxis.major_tick_line_color = None
236+
p.yaxis.major_tick_line_color = None
237+
p.xaxis.minor_tick_line_color = None
238+
p.yaxis.minor_tick_line_color = None
239+
240+
p.grid.grid_line_color = None
241+
242+
p.background_fill_color = "#FAFAFA"
243+
p.border_fill_color = "#FAFAFA"
244+
p.outline_line_color = None
245+
246+
# Save
247+
export_png(p, filename="plot.png")
248+
save(p, filename="plot.html", resources=CDN, title="scatter-pitch-events · bokeh · pyplots.ai")

0 commit comments

Comments
 (0)