|
| 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