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