Skip to content

Commit a8d7c69

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

2 files changed

Lines changed: 504 additions & 0 deletions

File tree

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
""" pyplots.ai
2+
scatter-pitch-events: Soccer Pitch Event Map
3+
Library: letsplot 4.9.0 | Python 3.14.3
4+
Quality: 88/100 | Created: 2026-03-20
5+
"""
6+
7+
import numpy as np
8+
import pandas as pd
9+
from lets_plot import (
10+
LetsPlot,
11+
aes,
12+
arrow,
13+
coord_fixed,
14+
element_rect,
15+
element_text,
16+
geom_path,
17+
geom_point,
18+
geom_rect,
19+
geom_segment,
20+
geom_text,
21+
ggplot,
22+
ggsave,
23+
ggsize,
24+
labs,
25+
scale_alpha_identity,
26+
scale_color_identity,
27+
scale_fill_identity,
28+
scale_shape_identity,
29+
scale_size_identity,
30+
theme,
31+
theme_void,
32+
xlim,
33+
ylim,
34+
)
35+
36+
37+
LetsPlot.setup_html()
38+
39+
# Data
40+
np.random.seed(42)
41+
42+
n_events = 100
43+
event_types = np.random.choice(["Pass", "Shot", "Tackle", "Interception"], size=n_events, p=[0.45, 0.15, 0.22, 0.18])
44+
45+
success_rates = {"Pass": 0.78, "Shot": 0.30, "Tackle": 0.60, "Interception": 0.70}
46+
outcomes = [
47+
np.random.choice(["Successful", "Unsuccessful"], p=[success_rates[et], 1 - success_rates[et]]) for et in event_types
48+
]
49+
50+
x_pos, y_pos, x_end, y_end = [], [], [], []
51+
for et in event_types:
52+
if et == "Pass":
53+
x = np.random.uniform(10, 95)
54+
y = np.random.uniform(5, 63)
55+
dx = np.random.uniform(5, 25) * np.random.choice([-1, 1], p=[0.2, 0.8])
56+
dy = np.random.uniform(-15, 15)
57+
xe, ye = np.clip(x + dx, 0, 105), np.clip(y + dy, 0, 68)
58+
elif et == "Shot":
59+
x = np.random.uniform(55, 100)
60+
y = np.random.uniform(10, 58)
61+
xe, ye = 105.0, np.random.uniform(28, 40)
62+
else:
63+
x = np.random.uniform(15, 85)
64+
y = np.random.uniform(5, 63)
65+
xe, ye = x, y
66+
x_pos.append(x)
67+
y_pos.append(y)
68+
x_end.append(xe)
69+
y_end.append(ye)
70+
71+
# Colorblind-safe palette (distinct hues: blue, red, orange, purple)
72+
pitch_green = "#2E7D32"
73+
pass_color = "#FFD700"
74+
shot_color = "#E63946"
75+
tackle_color = "#F77F00"
76+
intercept_color = "#7B2D8E"
77+
color_map = {"Pass": pass_color, "Shot": shot_color, "Tackle": tackle_color, "Interception": intercept_color}
78+
shape_map = {"Pass": 21, "Shot": 23, "Tackle": 24, "Interception": 22}
79+
80+
df = pd.DataFrame(
81+
{"x": x_pos, "y": y_pos, "x_end": x_end, "y_end": y_end, "event_type": event_types, "outcome": outcomes}
82+
)
83+
df["color"] = df["event_type"].map(color_map)
84+
df["shape"] = df["event_type"].map(shape_map)
85+
df["alpha"] = np.where(df["outcome"] == "Successful", 0.92, 0.85)
86+
df["fill"] = np.where(df["outcome"] == "Successful", df["color"], "#FFFFFF")
87+
df["marker_size"] = np.where(df["event_type"] == "Shot", 8.0, 5.0)
88+
89+
# Directional events (passes and shots)
90+
df_arrows = df[df["event_type"].isin(["Pass", "Shot"])].copy()
91+
92+
# Pitch markings
93+
theta = np.linspace(0, 2 * np.pi, 80)
94+
df_center_circle = pd.DataFrame({"x": 52.5 + 9.15 * np.cos(theta), "y": 34 + 9.15 * np.sin(theta)})
95+
96+
theta_l = np.linspace(-np.pi / 2, np.pi / 2, 40)
97+
arc_lx, arc_ly = 11 + 9.15 * np.cos(theta_l), 34 + 9.15 * np.sin(theta_l)
98+
mask_l = arc_lx >= 16.5
99+
df_left_arc = pd.DataFrame({"x": arc_lx[mask_l], "y": arc_ly[mask_l]})
100+
101+
theta_r = np.linspace(np.pi / 2, 3 * np.pi / 2, 40)
102+
arc_rx, arc_ry = 94 + 9.15 * np.cos(theta_r), 34 + 9.15 * np.sin(theta_r)
103+
mask_r = arc_rx <= 88.5
104+
df_right_arc = pd.DataFrame({"x": arc_rx[mask_r], "y": arc_ry[mask_r]})
105+
106+
# Corner arcs (radius = 1m)
107+
corner_positions = [
108+
(0, 0, 0, np.pi / 2),
109+
(0, 68, -np.pi / 2, 0),
110+
(105, 0, np.pi / 2, np.pi),
111+
(105, 68, np.pi, 3 * np.pi / 2),
112+
]
113+
corner_arc_dfs = []
114+
for cx, cy, t_start, t_end in corner_positions:
115+
t = np.linspace(t_start, t_end, 20)
116+
corner_arc_dfs.append(pd.DataFrame({"x": cx + 1.0 * np.cos(t), "y": cy + 1.0 * np.sin(t)}))
117+
118+
# Pitch rectangles
119+
df_rects = pd.DataFrame(
120+
{
121+
"xmin": [0, 0, 0, 88.5, 99.5],
122+
"ymin": [0, 13.84, 24.84, 13.84, 24.84],
123+
"xmax": [105, 16.5, 5.5, 105, 105],
124+
"ymax": [68, 54.16, 43.16, 54.16, 43.16],
125+
}
126+
)
127+
128+
# Legend labels positioned below the pitch
129+
legend_x = [12, 37, 62, 87]
130+
legend_y_marker = [-7.5] * 4
131+
legend_y_label = [-11.5] * 4
132+
legend_labels = ["Pass", "Shot", "Tackle", "Interception"]
133+
legend_colors = [pass_color, shot_color, tackle_color, intercept_color]
134+
legend_shapes = [21, 23, 24, 22]
135+
136+
df_legend_markers = pd.DataFrame(
137+
{"x": legend_x, "y": legend_y_marker, "color": legend_colors, "shape": legend_shapes, "fill": legend_colors}
138+
)
139+
df_legend_labels = pd.DataFrame({"x": legend_x, "y": legend_y_label, "label": legend_labels})
140+
141+
# Outcome annotation
142+
df_outcome_text = pd.DataFrame(
143+
{"x": [32, 72], "y": [-15.5, -15.5], "label": ["\u25cf Colored = Successful", "\u25cb White fill = Unsuccessful"]}
144+
)
145+
146+
# Zone highlights for storytelling (attacking and defensive thirds)
147+
df_attack_zone = pd.DataFrame({"xmin": [70], "ymin": [0], "xmax": [105], "ymax": [68]})
148+
df_defend_zone = pd.DataFrame({"xmin": [0], "ymin": [0], "xmax": [35], "ymax": [68]})
149+
150+
# Plot
151+
plot = (
152+
ggplot()
153+
# Pitch background
154+
+ geom_rect(
155+
aes(xmin="xmin", ymin="ymin", xmax="xmax", ymax="ymax"),
156+
data=pd.DataFrame({"xmin": [-4], "ymin": [-4], "xmax": [109], "ymax": [72]}),
157+
fill=pitch_green,
158+
color=pitch_green,
159+
)
160+
# Zone highlights
161+
+ geom_rect(
162+
aes(xmin="xmin", ymin="ymin", xmax="xmax", ymax="ymax"),
163+
data=df_attack_zone,
164+
fill="#FFFFFF",
165+
color="rgba(0,0,0,0)",
166+
alpha=0.08,
167+
)
168+
+ geom_rect(
169+
aes(xmin="xmin", ymin="ymin", xmax="xmax", ymax="ymax"),
170+
data=df_defend_zone,
171+
fill="#000000",
172+
color="rgba(0,0,0,0)",
173+
alpha=0.06,
174+
)
175+
# Pitch markings
176+
+ geom_rect(
177+
aes(xmin="xmin", ymin="ymin", xmax="xmax", ymax="ymax"),
178+
data=df_rects,
179+
fill="rgba(0,0,0,0)",
180+
color="#FFFFFF",
181+
size=1.0,
182+
)
183+
# Halfway line
184+
+ geom_segment(
185+
aes(x="x", y="y", xend="xend", yend="yend"),
186+
data=pd.DataFrame({"x": [52.5], "y": [0], "xend": [52.5], "yend": [68]}),
187+
color="#FFFFFF",
188+
size=1.0,
189+
)
190+
# Goal posts
191+
+ geom_segment(
192+
aes(x="x", y="y", xend="xend", yend="yend"),
193+
data=pd.DataFrame({"x": [0, 105], "y": [30.34, 30.34], "xend": [0, 105], "yend": [37.66, 37.66]}),
194+
color="#DDDDDD",
195+
size=2.5,
196+
)
197+
# Center circle and penalty arcs
198+
+ geom_path(data=df_center_circle, mapping=aes(x="x", y="y"), color="#FFFFFF", size=1.0)
199+
+ geom_path(data=df_left_arc, mapping=aes(x="x", y="y"), color="#FFFFFF", size=1.0)
200+
+ geom_path(data=df_right_arc, mapping=aes(x="x", y="y"), color="#FFFFFF", size=1.0)
201+
# Corner arcs
202+
+ geom_path(data=corner_arc_dfs[0], mapping=aes(x="x", y="y"), color="#FFFFFF", size=1.0)
203+
+ geom_path(data=corner_arc_dfs[1], mapping=aes(x="x", y="y"), color="#FFFFFF", size=1.0)
204+
+ geom_path(data=corner_arc_dfs[2], mapping=aes(x="x", y="y"), color="#FFFFFF", size=1.0)
205+
+ geom_path(data=corner_arc_dfs[3], mapping=aes(x="x", y="y"), color="#FFFFFF", size=1.0)
206+
# Spots
207+
+ geom_point(
208+
aes(x="x", y="y"), data=pd.DataFrame({"x": [52.5, 11, 94], "y": [34, 34, 34]}), color="#FFFFFF", size=2
209+
)
210+
# Directional arrows
211+
+ geom_segment(
212+
data=df_arrows,
213+
mapping=aes(x="x", y="y", xend="x_end", yend="y_end", color="color", alpha="alpha"),
214+
size=0.8,
215+
arrow=arrow(length=7, type="open"),
216+
)
217+
# Event markers with size encoding (shots larger for focal emphasis)
218+
+ geom_point(
219+
data=df,
220+
mapping=aes(x="x", y="y", color="color", fill="fill", shape="shape", alpha="alpha", size="marker_size"),
221+
stroke=1.5,
222+
)
223+
# Zone annotations
224+
+ geom_text(
225+
data=pd.DataFrame({"x": [87.5], "y": [65.5], "label": ["Attacking Third"]}),
226+
mapping=aes(x="x", y="y", label="label"),
227+
size=10,
228+
color="#FFFFFF",
229+
alpha=0.55,
230+
fontface="italic",
231+
)
232+
+ geom_text(
233+
data=pd.DataFrame({"x": [17.5], "y": [65.5], "label": ["Defensive Third"]}),
234+
mapping=aes(x="x", y="y", label="label"),
235+
size=10,
236+
color="#FFFFFF",
237+
alpha=0.45,
238+
fontface="italic",
239+
)
240+
# Legend markers
241+
+ geom_point(
242+
data=df_legend_markers, mapping=aes(x="x", y="y", color="color", fill="fill", shape="shape"), size=6, stroke=1.2
243+
)
244+
# Legend labels
245+
+ geom_text(
246+
data=df_legend_labels, mapping=aes(x="x", y="y", label="label"), size=15, color="#333333", fontface="bold"
247+
)
248+
# Outcome annotation
249+
+ geom_text(data=df_outcome_text, mapping=aes(x="x", y="y", label="label"), size=12, color="#555555")
250+
+ scale_color_identity()
251+
+ scale_fill_identity()
252+
+ scale_shape_identity()
253+
+ scale_alpha_identity()
254+
+ scale_size_identity()
255+
# Layout
256+
+ coord_fixed(ratio=1)
257+
+ xlim(-5, 112)
258+
+ ylim(-19, 76)
259+
+ labs(
260+
title="scatter-pitch-events \u00b7 letsplot \u00b7 pyplots.ai",
261+
subtitle="100 match events \u2014 passes, shots, tackles & interceptions with outcome encoding",
262+
)
263+
+ theme_void()
264+
+ theme(
265+
plot_title=element_text(size=26, hjust=0.5, color="#222222", face="bold"),
266+
plot_subtitle=element_text(size=16, hjust=0.5, color="#666666"),
267+
plot_background=element_rect(fill="#F5F5F0", color="#F5F5F0"),
268+
plot_margin=[40, 20, 20, 20],
269+
)
270+
+ ggsize(1600, 900)
271+
)
272+
273+
# Save
274+
ggsave(plot, "plot.png", path=".", scale=3)
275+
ggsave(plot, "plot.html", path=".")

0 commit comments

Comments
 (0)