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