Skip to content

Commit c9c68c4

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

2 files changed

Lines changed: 528 additions & 0 deletions

File tree

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
""" pyplots.ai
2+
scatter-pitch-events: Soccer Pitch Event Map
3+
Library: altair 6.0.0 | Python 3.14.3
4+
Quality: 89/100 | Created: 2026-03-20
5+
"""
6+
7+
import altair as alt
8+
import numpy as np
9+
import pandas as pd
10+
11+
12+
# Data
13+
np.random.seed(42)
14+
n_events = 120
15+
event_types = np.random.choice(["Pass", "Shot", "Tackle", "Interception"], size=n_events, p=[0.50, 0.15, 0.20, 0.15])
16+
17+
x = np.zeros(n_events)
18+
y = np.zeros(n_events)
19+
end_x = np.zeros(n_events)
20+
end_y = np.zeros(n_events)
21+
22+
for i, etype in enumerate(event_types):
23+
if etype == "Pass":
24+
x[i] = np.random.uniform(10, 95)
25+
y[i] = np.random.uniform(5, 63)
26+
end_x[i] = np.clip(x[i] + np.random.uniform(-15, 25), 0, 105)
27+
end_y[i] = np.clip(y[i] + np.random.uniform(-12, 12), 0, 68)
28+
elif etype == "Shot":
29+
x[i] = np.random.uniform(60, 98)
30+
y[i] = np.random.uniform(15, 53)
31+
# Shorter shot arrows: end 60% of the way toward the goal to reduce congestion
32+
target_x = 105
33+
target_y = 34 + np.random.uniform(-4, 4)
34+
end_x[i] = x[i] + 0.6 * (target_x - x[i])
35+
end_y[i] = y[i] + 0.6 * (target_y - y[i])
36+
elif etype == "Tackle":
37+
x[i] = np.random.uniform(15, 80)
38+
y[i] = np.random.uniform(5, 63)
39+
elif etype == "Interception":
40+
x[i] = np.random.uniform(20, 75)
41+
y[i] = np.random.uniform(5, 63)
42+
43+
outcomes = np.where(np.random.random(n_events) < 0.65, "Successful", "Unsuccessful")
44+
45+
df = pd.DataFrame({"x": x, "y": y, "end_x": end_x, "end_y": end_y, "event_type": event_types, "outcome": outcomes})
46+
47+
# Bolder colorblind-safe palette: vivid blue, warm orange, strong teal, rich purple
48+
color_domain = ["Pass", "Shot", "Tackle", "Interception"]
49+
color_range = ["#2171b5", "#e6550d", "#1b9e77", "#7b3294"]
50+
51+
# Marker sizes: shots larger to create visual hierarchy (danger zone focal point)
52+
df["marker_size"] = np.where(df["event_type"] == "Shot", 280, 160)
53+
54+
# Compute arrowhead positions (small triangle at 85% along each direction line)
55+
arrows_df = df[df["event_type"].isin(["Pass", "Shot"])].copy()
56+
arrow_frac = 0.85
57+
arrows_df["arrow_x"] = arrows_df["x"] + arrow_frac * (arrows_df["end_x"] - arrows_df["x"])
58+
arrows_df["arrow_y"] = arrows_df["y"] + arrow_frac * (arrows_df["end_y"] - arrows_df["y"])
59+
dx = arrows_df["end_x"] - arrows_df["x"]
60+
dy = arrows_df["end_y"] - arrows_df["y"]
61+
arrows_df["angle"] = np.degrees(np.arctan2(dy, dx))
62+
63+
# Pitch zone shading — highlight attacking third as "danger zone" for storytelling
64+
zones_data = pd.DataFrame(
65+
{
66+
"x": [0, 35, 70],
67+
"y": [0, 0, 0],
68+
"x2": [35, 70, 105],
69+
"y2": [68, 68, 68],
70+
"zone": ["Defensive Third", "Middle Third", "Attacking Third"],
71+
"fill": ["#1a472a", "#1f5432", "#2d6a3f"],
72+
"zone_opacity": [0.28, 0.25, 0.35],
73+
}
74+
)
75+
76+
# Pitch markings - line segments
77+
lines_data = pd.DataFrame(
78+
{
79+
"x": [0, 0, 105, 0, 52.5, 0, 16.5, 16.5, 0, 5.5, 5.5, 105, 88.5, 88.5, 105, 99.5, 99.5],
80+
"y": [0, 0, 0, 68, 0, 13.84, 13.84, 54.16, 24.84, 24.84, 43.16, 13.84, 13.84, 54.16, 24.84, 24.84, 43.16],
81+
"x2": [105, 0, 105, 105, 52.5, 16.5, 16.5, 0, 5.5, 5.5, 0, 88.5, 88.5, 105, 99.5, 99.5, 105],
82+
"y2": [0, 68, 68, 68, 68, 13.84, 54.16, 54.16, 24.84, 43.16, 43.16, 13.84, 54.16, 54.16, 24.84, 43.16, 43.16],
83+
}
84+
)
85+
86+
# Center circle points
87+
theta = np.linspace(0, 2 * np.pi, 60)
88+
center_circle = pd.DataFrame({"x": 52.5 + 9.15 * np.cos(theta), "y": 34 + 9.15 * np.sin(theta), "order": range(60)})
89+
90+
# Left penalty arc (outside penalty area, center at 11, 34)
91+
arc_theta = np.linspace(-0.65, 0.65, 30)
92+
left_arc = pd.DataFrame({"x": 11 + 9.15 * np.cos(arc_theta), "y": 34 + 9.15 * np.sin(arc_theta), "order": range(30)})
93+
94+
# Right penalty arc (outside penalty area, center at 94, 34)
95+
right_arc = pd.DataFrame(
96+
{"x": 94 + 9.15 * np.cos(np.pi - arc_theta), "y": 34 + 9.15 * np.sin(np.pi - arc_theta), "order": range(30)}
97+
)
98+
99+
# Corner arcs
100+
corner_arcs = []
101+
for cx, cy, t_start, t_end in [
102+
(0, 0, 0, np.pi / 2),
103+
(0, 68, -np.pi / 2, 0),
104+
(105, 0, np.pi / 2, np.pi),
105+
(105, 68, np.pi, 3 * np.pi / 2),
106+
]:
107+
t = np.linspace(t_start, t_end, 15)
108+
corner_arcs.append(pd.DataFrame({"x": cx + 1 * np.cos(t), "y": cy + 1 * np.sin(t), "order": range(15)}))
109+
110+
# Spots
111+
spots = pd.DataFrame({"x": [52.5, 11, 94], "y": [34, 34, 34]})
112+
113+
# Pitch zone backgrounds — gradient from dark to lighter green toward attacking third
114+
zone_layers = []
115+
for _, row in zones_data.iterrows():
116+
zone_layers.append(
117+
alt.Chart(pd.DataFrame({"x": [row["x"]], "y": [row["y"]], "x2": [row["x2"]], "y2": [row["y2"]]}))
118+
.mark_rect(color=row["fill"], opacity=row["zone_opacity"])
119+
.encode(x="x:Q", y="y:Q", x2="x2:Q", y2="y2:Q")
120+
)
121+
122+
# Pitch lines — white lines on dark pitch for crisp contrast
123+
pitch_lines = (
124+
alt.Chart(lines_data)
125+
.mark_rule(color="rgba(255,255,255,0.75)", strokeWidth=1.8)
126+
.encode(x="x:Q", y="y:Q", x2="x2:Q", y2="y2:Q")
127+
)
128+
129+
# Shared axis config — tighter domain for better canvas utilization
130+
x_axis = alt.X(
131+
"x:Q",
132+
scale=alt.Scale(domain=[-1.5, 106.5]),
133+
axis=alt.Axis(title=None, labels=False, ticks=False, grid=False, domain=False),
134+
)
135+
y_axis = alt.Y(
136+
"y:Q",
137+
scale=alt.Scale(domain=[-1.5, 69.5]),
138+
axis=alt.Axis(title=None, labels=False, ticks=False, grid=False, domain=False),
139+
)
140+
141+
# Center circle layer
142+
circle_layer = (
143+
alt.Chart(center_circle)
144+
.mark_line(color="rgba(255,255,255,0.75)", strokeWidth=1.8, filled=False)
145+
.encode(x=x_axis, y=y_axis, order="order:O")
146+
)
147+
148+
# Penalty arc layers
149+
left_arc_layer = (
150+
alt.Chart(left_arc)
151+
.mark_line(color="rgba(255,255,255,0.75)", strokeWidth=1.8)
152+
.encode(x=x_axis, y=y_axis, order="order:O")
153+
)
154+
right_arc_layer = (
155+
alt.Chart(right_arc)
156+
.mark_line(color="rgba(255,255,255,0.75)", strokeWidth=1.8)
157+
.encode(x=x_axis, y=y_axis, order="order:O")
158+
)
159+
160+
# Corner arc layers
161+
corner_layers = [
162+
alt.Chart(ca).mark_line(color="rgba(255,255,255,0.75)", strokeWidth=1.8).encode(x=x_axis, y=y_axis, order="order:O")
163+
for ca in corner_arcs
164+
]
165+
166+
# Spots — white to match pitch lines
167+
spot_layer = alt.Chart(spots).mark_point(color="rgba(255,255,255,0.8)", size=45, filled=True).encode(x=x_axis, y=y_axis)
168+
169+
# Direction lines for passes and shots
170+
arrow_lines = (
171+
alt.Chart(arrows_df)
172+
.mark_rule(strokeWidth=1.1)
173+
.encode(
174+
x="x:Q",
175+
y="y:Q",
176+
x2="end_x:Q",
177+
y2="end_y:Q",
178+
color=alt.Color("event_type:N", scale=alt.Scale(domain=color_domain, range=color_range), legend=None),
179+
opacity=alt.Opacity(
180+
"outcome:N", scale=alt.Scale(domain=["Successful", "Unsuccessful"], range=[0.45, 0.20]), legend=None
181+
),
182+
)
183+
)
184+
185+
# Arrowheads as rotated triangles at the end of direction lines
186+
arrowheads = (
187+
alt.Chart(arrows_df)
188+
.mark_point(shape="triangle-right", filled=True, size=90, stroke=None)
189+
.encode(
190+
x=alt.X("arrow_x:Q", scale=alt.Scale(domain=[-1.5, 106.5]), axis=None),
191+
y=alt.Y("arrow_y:Q", scale=alt.Scale(domain=[-1.5, 69.5]), axis=None),
192+
color=alt.Color("event_type:N", scale=alt.Scale(domain=color_domain, range=color_range), legend=None),
193+
angle=alt.Angle("angle:Q", scale=alt.Scale(domain=[-180, 180], range=[-180, 180])),
194+
opacity=alt.Opacity(
195+
"outcome:N", scale=alt.Scale(domain=["Successful", "Unsuccessful"], range=[0.75, 0.35]), legend=None
196+
),
197+
)
198+
)
199+
200+
# Event markers — size encoding creates visual hierarchy (shots stand out in the danger zone)
201+
event_points = (
202+
alt.Chart(df)
203+
.mark_point(filled=True, stroke="#ffffff", strokeWidth=1.0)
204+
.encode(
205+
x=x_axis,
206+
y=y_axis,
207+
color=alt.Color(
208+
"event_type:N",
209+
scale=alt.Scale(domain=color_domain, range=color_range),
210+
legend=alt.Legend(
211+
title="Event Type",
212+
titleFontSize=18,
213+
titleFontWeight="bold",
214+
labelFontSize=16,
215+
symbolSize=220,
216+
orient="right",
217+
titleColor="#222222",
218+
labelColor="#333333",
219+
),
220+
),
221+
shape=alt.Shape(
222+
"event_type:N",
223+
scale=alt.Scale(
224+
domain=["Pass", "Shot", "Tackle", "Interception"],
225+
range=["circle", "triangle-right", "triangle-up", "diamond"],
226+
),
227+
legend=None,
228+
),
229+
size=alt.Size("marker_size:Q", scale=alt.Scale(domain=[160, 280], range=[160, 280]), legend=None),
230+
opacity=alt.Opacity(
231+
"outcome:N",
232+
scale=alt.Scale(domain=["Successful", "Unsuccessful"], range=[0.92, 0.42]),
233+
legend=alt.Legend(
234+
title="Outcome",
235+
titleFontSize=18,
236+
titleFontWeight="bold",
237+
labelFontSize=16,
238+
symbolSize=220,
239+
orient="right",
240+
titleColor="#222222",
241+
labelColor="#333333",
242+
),
243+
),
244+
tooltip=[
245+
alt.Tooltip("event_type:N", title="Event"),
246+
alt.Tooltip("outcome:N", title="Outcome"),
247+
alt.Tooltip("x:Q", title="X (m)", format=".1f"),
248+
alt.Tooltip("y:Q", title="Y (m)", format=".1f"),
249+
],
250+
)
251+
)
252+
253+
# Compose all layers
254+
chart = (
255+
alt.layer(
256+
*zone_layers,
257+
pitch_lines,
258+
circle_layer,
259+
left_arc_layer,
260+
right_arc_layer,
261+
*corner_layers,
262+
spot_layer,
263+
arrow_lines,
264+
arrowheads,
265+
event_points,
266+
)
267+
.properties(
268+
width=1600,
269+
height=round(1600 * 72 / 105),
270+
title=alt.Title(
271+
"scatter-pitch-events · altair · pyplots.ai",
272+
fontSize=28,
273+
fontWeight="bold",
274+
color="#1a1a1a",
275+
subtitle="Match events: passes, shots, tackles, and interceptions — shots highlighted in the attacking third",
276+
subtitleFontSize=19,
277+
subtitleColor="#555555",
278+
subtitlePadding=8,
279+
),
280+
)
281+
.configure_view(strokeWidth=0)
282+
.configure_legend(fillColor="#f8f9fa", strokeColor="#d0d0d0", padding=12, cornerRadius=6, titlePadding=6)
283+
.resolve_scale(
284+
color="independent", opacity="independent", shape="independent", angle="independent", size="independent"
285+
)
286+
.interactive()
287+
)
288+
289+
# Save
290+
chart.save("plot.png", scale_factor=3.0)
291+
chart.save("plot.html")

0 commit comments

Comments
 (0)