Skip to content

Commit 00a6a97

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

2 files changed

Lines changed: 447 additions & 0 deletions

File tree

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
""" pyplots.ai
2+
scatter-pitch-events: Soccer Pitch Event Map
3+
Library: plotnine 0.15.3 | Python 3.14.3
4+
Quality: 90/100 | Created: 2026-03-20
5+
"""
6+
7+
import numpy as np
8+
import pandas as pd
9+
from plotnine import (
10+
aes,
11+
annotate,
12+
arrow,
13+
coord_fixed,
14+
element_blank,
15+
element_rect,
16+
element_text,
17+
geom_path,
18+
geom_point,
19+
geom_segment,
20+
ggplot,
21+
guide_legend,
22+
guides,
23+
labs,
24+
scale_alpha_manual,
25+
scale_color_manual,
26+
scale_shape_manual,
27+
scale_x_continuous,
28+
scale_y_continuous,
29+
theme,
30+
)
31+
32+
33+
# Data
34+
np.random.seed(42)
35+
36+
n_events = 120
37+
event_types = np.random.choice(["Pass", "Shot", "Tackle", "Interception"], size=n_events, p=[0.50, 0.15, 0.20, 0.15])
38+
39+
# Vectorized position generation with event-specific distributions
40+
x_ranges = {"Pass": (10, 95), "Shot": (65, 100), "Tackle": (15, 80), "Interception": (20, 75)}
41+
y_ranges = {"Pass": (5, 63), "Shot": (15, 53), "Tackle": (5, 63), "Interception": (5, 63)}
42+
success_p = {"Pass": 0.75, "Shot": 0.30, "Tackle": 0.65, "Interception": 0.70}
43+
44+
x_positions = np.array([np.random.uniform(*x_ranges[e]) for e in event_types])
45+
y_positions = np.array([np.random.uniform(*y_ranges[e]) for e in event_types])
46+
outcomes = [np.random.choice(["Successful", "Unsuccessful"], p=[success_p[e], 1 - success_p[e]]) for e in event_types]
47+
48+
# Arrow endpoints: passes forward-biased, shots toward goal, others stay in place
49+
x_end = x_positions.copy()
50+
y_end = y_positions.copy()
51+
for i, evt in enumerate(event_types):
52+
if evt == "Pass":
53+
dx = np.random.uniform(5, 20) * np.random.choice([-1, 1], p=[0.15, 0.85])
54+
dy = np.random.uniform(-12, 12)
55+
x_end[i] = np.clip(x_positions[i] + dx, 0, 105)
56+
y_end[i] = np.clip(y_positions[i] + dy, 0, 68)
57+
elif evt == "Shot":
58+
x_end[i] = 105
59+
y_end[i] = np.random.uniform(28, 40)
60+
61+
df = pd.DataFrame(
62+
{
63+
"x": x_positions,
64+
"y": y_positions,
65+
"x_end": x_end,
66+
"y_end": y_end,
67+
"event_type": pd.Categorical(event_types, categories=["Pass", "Shot", "Tackle", "Interception"]),
68+
"outcome": pd.Categorical(outcomes, categories=["Successful", "Unsuccessful"]),
69+
}
70+
)
71+
72+
# Separate layers for visual hierarchy
73+
df_shots = df[df["event_type"] == "Shot"].copy()
74+
df_other = df[df["event_type"] != "Shot"].copy()
75+
df_pass_arrows = df[df["event_type"] == "Pass"].copy()
76+
df_shot_arrows = df[df["event_type"] == "Shot"].copy()
77+
78+
# Pitch styling — deep green with cream white lines for a premium look
79+
pitch_color = "#1a6b30"
80+
line_color = "#ffffffcc"
81+
lw = 0.7
82+
83+
# Center circle
84+
theta = np.linspace(0, 2 * np.pi, 100)
85+
center_circle = pd.DataFrame({"cx": 52.5 + 9.15 * np.cos(theta), "cy": 34 + 9.15 * np.sin(theta), "grp": 1})
86+
87+
# Penalty arcs
88+
theta_left = np.linspace(-0.65, 0.65, 50)
89+
left_arc = pd.DataFrame({"cx": 11 + 9.15 * np.cos(theta_left), "cy": 34 + 9.15 * np.sin(theta_left), "grp": 2})
90+
theta_right = np.linspace(np.pi - 0.65, np.pi + 0.65, 50)
91+
right_arc = pd.DataFrame({"cx": 94 + 9.15 * np.cos(theta_right), "cy": 34 + 9.15 * np.sin(theta_right), "grp": 3})
92+
93+
# Corner arcs
94+
ca_r = 1.0
95+
corner_arcs = pd.concat(
96+
[
97+
pd.DataFrame(
98+
{
99+
"cx": ca_r * np.cos(np.linspace(0, np.pi / 2, 20)),
100+
"cy": ca_r * np.sin(np.linspace(0, np.pi / 2, 20)),
101+
"grp": 4,
102+
}
103+
),
104+
pd.DataFrame(
105+
{
106+
"cx": ca_r * np.cos(np.linspace(np.pi / 2, np.pi, 20)),
107+
"cy": 68 + ca_r * np.sin(np.linspace(np.pi / 2, np.pi, 20)),
108+
"grp": 5,
109+
}
110+
),
111+
pd.DataFrame(
112+
{
113+
"cx": 105 + ca_r * np.cos(np.linspace(-np.pi / 2, 0, 20)),
114+
"cy": ca_r * np.sin(np.linspace(-np.pi / 2, 0, 20)),
115+
"grp": 6,
116+
}
117+
),
118+
pd.DataFrame(
119+
{
120+
"cx": 105 + ca_r * np.cos(np.linspace(np.pi, 3 * np.pi / 2, 20)),
121+
"cy": 68 + ca_r * np.sin(np.linspace(np.pi, 3 * np.pi / 2, 20)),
122+
"grp": 7,
123+
}
124+
),
125+
],
126+
ignore_index=True,
127+
)
128+
129+
all_curves = pd.concat([center_circle, left_arc, right_arc, corner_arcs], ignore_index=True)
130+
131+
# Colorblind-safe palette: blue, red, orange, teal — all perceptually distinct
132+
event_colors = {"Pass": "#4a90d9", "Shot": "#d94452", "Tackle": "#e8913a", "Interception": "#17a589"}
133+
event_shapes = {"Pass": "o", "Shot": "*", "Tackle": "^", "Interception": "D"}
134+
# Plot
135+
plot = (
136+
ggplot(df, aes(x="x", y="y", color="event_type", shape="event_type", alpha="outcome"))
137+
# Pitch background — extended to fill canvas edges
138+
+ annotate("rect", xmin=-5, xmax=110, ymin=-5, ymax=73, fill=pitch_color, color=pitch_color)
139+
# Subtle pitch grass stripe effect (lighter bands)
140+
+ annotate("rect", xmin=0, xmax=105, ymin=0, ymax=68, fill="#1e7535", alpha=0.3, color="none")
141+
# Pitch outline
142+
+ annotate("rect", xmin=0, xmax=105, ymin=0, ymax=68, fill="none", color=line_color, size=lw)
143+
# Halfway line
144+
+ annotate("segment", x=52.5, xend=52.5, y=0, yend=68, color=line_color, size=lw)
145+
# Center spot
146+
+ annotate("point", x=52.5, y=34, color=line_color, size=1.8, shape="o", fill=line_color)
147+
# Left penalty area
148+
+ annotate("rect", xmin=0, xmax=16.5, ymin=13.84, ymax=54.16, fill="none", color=line_color, size=lw)
149+
# Right penalty area
150+
+ annotate("rect", xmin=88.5, xmax=105, ymin=13.84, ymax=54.16, fill="none", color=line_color, size=lw)
151+
# Left goal area
152+
+ annotate("rect", xmin=0, xmax=5.5, ymin=24.84, ymax=43.16, fill="none", color=line_color, size=lw)
153+
# Right goal area
154+
+ annotate("rect", xmin=99.5, xmax=105, ymin=24.84, ymax=43.16, fill="none", color=line_color, size=lw)
155+
# Penalty spots
156+
+ annotate("point", x=11, y=34, color=line_color, size=1.2, shape="o", fill=line_color)
157+
+ annotate("point", x=94, y=34, color=line_color, size=1.2, shape="o", fill=line_color)
158+
# Left goal
159+
+ annotate("segment", x=-2, xend=-2, y=30.34, yend=37.66, color="#ffffff", size=1.5)
160+
+ annotate("segment", x=-2, xend=0, y=30.34, yend=30.34, color="#ffffff", size=0.5)
161+
+ annotate("segment", x=-2, xend=0, y=37.66, yend=37.66, color="#ffffff", size=0.5)
162+
# Right goal
163+
+ annotate("segment", x=107, xend=107, y=30.34, yend=37.66, color="#ffffff", size=1.5)
164+
+ annotate("segment", x=105, xend=107, y=30.34, yend=30.34, color="#ffffff", size=0.5)
165+
+ annotate("segment", x=105, xend=107, y=37.66, yend=37.66, color="#ffffff", size=0.5)
166+
# Curves: center circle, penalty arcs, corner arcs
167+
+ geom_path(data=all_curves, mapping=aes(x="cx", y="cy", group="grp"), color=line_color, size=lw, inherit_aes=False)
168+
# Pass arrows — thin and subtle to avoid midfield clutter
169+
+ geom_segment(
170+
data=df_pass_arrows,
171+
mapping=aes(x="x", y="y", xend="x_end", yend="y_end", alpha="outcome"),
172+
color=event_colors["Pass"],
173+
size=0.4,
174+
arrow=arrow(length=0.10, type="open"),
175+
inherit_aes=False,
176+
)
177+
# Shot arrows — bolder to emphasize attacking intent
178+
+ geom_segment(
179+
data=df_shot_arrows,
180+
mapping=aes(x="x", y="y", xend="x_end", yend="y_end", alpha="outcome"),
181+
color=event_colors["Shot"],
182+
size=0.9,
183+
arrow=arrow(length=0.18, type="open"),
184+
inherit_aes=False,
185+
)
186+
# Non-shot markers
187+
+ geom_point(data=df_other, size=4.5, stroke=0.4)
188+
# Shot markers — larger for focal emphasis
189+
+ geom_point(data=df_shots, size=8, stroke=0.4)
190+
# Scales
191+
+ scale_color_manual(values=event_colors, name="Event Type")
192+
+ scale_shape_manual(values=event_shapes, name="Event Type")
193+
+ scale_alpha_manual(values={"Successful": 0.92, "Unsuccessful": 0.40}, name="Outcome")
194+
+ scale_x_continuous(limits=(-5, 110), breaks=[])
195+
+ scale_y_continuous(limits=(-5, 73), breaks=[])
196+
+ coord_fixed(ratio=1)
197+
+ labs(
198+
title="scatter-pitch-events · plotnine · pyplots.ai",
199+
subtitle="Match events: 120 actions across passes, shots, tackles & interceptions",
200+
)
201+
+ guides(
202+
color=guide_legend(override_aes={"size": 5}),
203+
alpha=guide_legend(override_aes={"size": 5, "alpha": [0.92, 0.40]}),
204+
)
205+
+ theme(
206+
figure_size=(16, 9),
207+
plot_title=element_text(size=24, weight="bold", color="#1a1a1a", margin={"b": 4}),
208+
plot_subtitle=element_text(size=16, color="#555555", style="italic", margin={"b": 12}),
209+
panel_background=element_rect(fill=pitch_color, color="none"),
210+
plot_background=element_rect(fill="#f5f5f0", color="none"),
211+
panel_grid_major=element_blank(),
212+
panel_grid_minor=element_blank(),
213+
axis_title=element_blank(),
214+
axis_text=element_blank(),
215+
axis_ticks=element_blank(),
216+
legend_title=element_text(size=16, weight="bold", color="#2a2a2a"),
217+
legend_text=element_text(size=14, color="#3a3a3a"),
218+
legend_position="right",
219+
legend_background=element_rect(fill="#f5f5f0", color="none"),
220+
legend_key=element_rect(fill="#f5f5f0", color="none"),
221+
plot_margin=0.02,
222+
)
223+
)
224+
225+
# Save
226+
plot.save("plot.png", dpi=300, verbose=False)

0 commit comments

Comments
 (0)