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