|
| 1 | +""" pyplots.ai |
| 2 | +scatter-pitch-events: Soccer Pitch Event Map |
| 3 | +Library: highcharts unknown | Python 3.14.3 |
| 4 | +Quality: 89/100 | Created: 2026-03-20 |
| 5 | +""" |
| 6 | + |
| 7 | +import json |
| 8 | +import tempfile |
| 9 | +import time |
| 10 | +import urllib.request |
| 11 | +from pathlib import Path |
| 12 | + |
| 13 | +import numpy as np |
| 14 | +from highcharts_core.chart import Chart |
| 15 | +from highcharts_core.options import HighchartsOptions |
| 16 | +from highcharts_core.options.series.scatter import ScatterSeries |
| 17 | +from selenium import webdriver |
| 18 | +from selenium.webdriver.chrome.options import Options |
| 19 | + |
| 20 | + |
| 21 | +# Data - Synthetic match event data |
| 22 | +np.random.seed(42) |
| 23 | + |
| 24 | +# Colorblind-safe palette (Tol's qualitative) - all pairs distinguishable |
| 25 | +events = { |
| 26 | + "Pass": { |
| 27 | + "n": 55, |
| 28 | + "color": "#4477AA", |
| 29 | + "symbol": "circle", |
| 30 | + "radius": 13, |
| 31 | + "z": 4, |
| 32 | + "success_rate": 0.78, |
| 33 | + "x_range": (10, 90), |
| 34 | + "y_range": (5, 63), |
| 35 | + "has_arrow": True, |
| 36 | + }, |
| 37 | + "Shot": { |
| 38 | + "n": 18, |
| 39 | + "color": "#EE6677", |
| 40 | + "symbol": "triangle", |
| 41 | + "radius": 18, |
| 42 | + "z": 8, |
| 43 | + "success_rate": 0.28, |
| 44 | + "x_range": (65, 100), |
| 45 | + "y_range": (18, 50), |
| 46 | + "has_arrow": True, |
| 47 | + }, |
| 48 | + "Tackle": { |
| 49 | + "n": 25, |
| 50 | + "color": "#CCBB44", |
| 51 | + "symbol": "triangle-down", |
| 52 | + "radius": 14, |
| 53 | + "z": 5, |
| 54 | + "success_rate": 0.68, |
| 55 | + "x_range": (10, 75), |
| 56 | + "y_range": (5, 63), |
| 57 | + "has_arrow": False, |
| 58 | + }, |
| 59 | + "Interception": { |
| 60 | + "n": 22, |
| 61 | + "color": "#AA3377", |
| 62 | + "symbol": "diamond", |
| 63 | + "radius": 14, |
| 64 | + "z": 5, |
| 65 | + "success_rate": 0.72, |
| 66 | + "x_range": (25, 80), |
| 67 | + "y_range": (5, 63), |
| 68 | + "has_arrow": False, |
| 69 | + }, |
| 70 | +} |
| 71 | + |
| 72 | +arrows = [] |
| 73 | + |
| 74 | +# Build chart using highcharts-core Python API |
| 75 | +chart = Chart(container="container") |
| 76 | +chart.options = HighchartsOptions() |
| 77 | + |
| 78 | +chart.options.chart = { |
| 79 | + "type": "scatter", |
| 80 | + "width": 4800, |
| 81 | + "height": 2700, |
| 82 | + "backgroundColor": "#1a1a2e", |
| 83 | + "plotBackgroundColor": { |
| 84 | + "linearGradient": {"x1": 0, "y1": 0, "x2": 0, "y2": 1}, |
| 85 | + "stops": [[0, "#2d7a32"], [0.5, "#256b28"], [1, "#1e5c20"]], |
| 86 | + }, |
| 87 | + "marginBottom": 180, |
| 88 | + "marginTop": 160, |
| 89 | + "marginLeft": 100, |
| 90 | + "marginRight": 80, |
| 91 | + "style": {"fontFamily": "'Segoe UI', Helvetica, Arial, sans-serif"}, |
| 92 | +} |
| 93 | + |
| 94 | +chart.options.title = { |
| 95 | + "text": "scatter-pitch-events \u00b7 highcharts \u00b7 pyplots.ai", |
| 96 | + "style": {"fontSize": "46px", "fontWeight": "600", "color": "#e8e8e8", "letterSpacing": "0.5px"}, |
| 97 | +} |
| 98 | + |
| 99 | +chart.options.subtitle = { |
| 100 | + "text": ( |
| 101 | + '<span style="font-size:30px;color:#aaa;">' |
| 102 | + "\u25cf Filled = Successful \u00a0\u00a0" |
| 103 | + "\u25cb White = Unsuccessful \u00a0\u00a0" |
| 104 | + "\u2192 Arrows show pass/shot trajectory \u00a0\u00a0" |
| 105 | + "| Shots enlarged for tactical emphasis" |
| 106 | + "</span>" |
| 107 | + ), |
| 108 | + "useHTML": True, |
| 109 | + "style": {"fontSize": "30px"}, |
| 110 | +} |
| 111 | + |
| 112 | +chart.options.x_axis = { |
| 113 | + "min": -14.5, |
| 114 | + "max": 119.5, |
| 115 | + "title": {"enabled": False}, |
| 116 | + "labels": {"enabled": False}, |
| 117 | + "gridLineWidth": 0, |
| 118 | + "lineWidth": 0, |
| 119 | + "tickWidth": 0, |
| 120 | +} |
| 121 | + |
| 122 | +chart.options.y_axis = { |
| 123 | + "min": -2, |
| 124 | + "max": 70, |
| 125 | + "title": {"enabled": False}, |
| 126 | + "labels": {"enabled": False}, |
| 127 | + "gridLineWidth": 0, |
| 128 | + "lineWidth": 0, |
| 129 | + "tickWidth": 0, |
| 130 | +} |
| 131 | + |
| 132 | +chart.options.legend = { |
| 133 | + "enabled": True, |
| 134 | + "floating": True, |
| 135 | + "verticalAlign": "top", |
| 136 | + "align": "left", |
| 137 | + "x": 120, |
| 138 | + "y": 80, |
| 139 | + "layout": "horizontal", |
| 140 | + "itemStyle": {"fontSize": "30px", "fontWeight": "normal", "color": "#ddd"}, |
| 141 | + "itemHoverStyle": {"color": "#fff"}, |
| 142 | + "symbolRadius": 0, |
| 143 | + "symbolWidth": 28, |
| 144 | + "symbolHeight": 28, |
| 145 | + "itemDistance": 40, |
| 146 | + "backgroundColor": "rgba(26,26,46,0.85)", |
| 147 | + "borderRadius": 10, |
| 148 | + "padding": 18, |
| 149 | + "shadow": True, |
| 150 | +} |
| 151 | + |
| 152 | +chart.options.credits = {"enabled": False} |
| 153 | + |
| 154 | +chart.options.tooltip = { |
| 155 | + "headerFormat": "", |
| 156 | + "pointFormat": ('<b style="color:{series.color}">{series.name}</b><br/>Position: ({point.x:.0f}m, {point.y:.0f}m)'), |
| 157 | + "style": {"fontSize": "20px"}, |
| 158 | + "backgroundColor": "rgba(26,26,46,0.92)", |
| 159 | + "borderColor": "#555", |
| 160 | + "shadow": {"color": "rgba(0,0,0,0.3)"}, |
| 161 | +} |
| 162 | + |
| 163 | +chart.options.plot_options = { |
| 164 | + "scatter": {"shadow": {"color": "rgba(0,0,0,0.3)", "offsetX": 0, "offsetY": 2, "width": 6}} |
| 165 | +} |
| 166 | + |
| 167 | +# Add series using ScatterSeries API |
| 168 | +for name, cfg in events.items(): |
| 169 | + n = cfg["n"] |
| 170 | + x = np.random.uniform(*cfg["x_range"], n) |
| 171 | + y = np.random.uniform(*cfg["y_range"], n) |
| 172 | + ok = np.random.random(n) < cfg["success_rate"] |
| 173 | + |
| 174 | + data = [ |
| 175 | + { |
| 176 | + "x": round(float(x[i]), 1), |
| 177 | + "y": round(float(y[i]), 1), |
| 178 | + "marker": { |
| 179 | + "fillColor": cfg["color"] if ok[i] else "rgba(255,255,255,0.75)", |
| 180 | + "lineColor": cfg["color"], |
| 181 | + "lineWidth": 2 if ok[i] else 3, |
| 182 | + }, |
| 183 | + } |
| 184 | + for i in range(n) |
| 185 | + ] |
| 186 | + |
| 187 | + series = ScatterSeries() |
| 188 | + series.name = name |
| 189 | + series.color = cfg["color"] |
| 190 | + series.marker = {"symbol": cfg["symbol"], "radius": cfg["radius"], "lineColor": cfg["color"], "lineWidth": 2} |
| 191 | + series.data = data |
| 192 | + series.z_index = cfg["z"] |
| 193 | + chart.add_series(series) |
| 194 | + |
| 195 | + if cfg["has_arrow"]: |
| 196 | + if name == "Shot": |
| 197 | + ex = np.full(n, 105.0) |
| 198 | + ey = np.clip(34 + np.random.normal(0, 5, n), 24, 44) |
| 199 | + else: |
| 200 | + ex = np.clip(x + np.random.normal(15, 10, n), 0, 105) |
| 201 | + ey = np.clip(y + np.random.normal(0, 12, n), 0, 68) |
| 202 | + for i in range(n): |
| 203 | + arrows.append( |
| 204 | + { |
| 205 | + "x1": round(float(x[i]), 1), |
| 206 | + "y1": round(float(y[i]), 1), |
| 207 | + "x2": round(float(ex[i]), 1), |
| 208 | + "y2": round(float(ey[i]), 1), |
| 209 | + "c": cfg["color"], |
| 210 | + "ok": bool(ok[i]), |
| 211 | + } |
| 212 | + ) |
| 213 | + |
| 214 | +# Download Highcharts JS |
| 215 | +cdn_urls = ["https://code.highcharts.com/highcharts.js", "https://cdn.jsdelivr.net/npm/highcharts@11/highcharts.js"] |
| 216 | +highcharts_js = None |
| 217 | +for url in cdn_urls: |
| 218 | + try: |
| 219 | + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) |
| 220 | + with urllib.request.urlopen(req, timeout=30) as response: |
| 221 | + highcharts_js = response.read().decode("utf-8") |
| 222 | + break |
| 223 | + except Exception: |
| 224 | + continue |
| 225 | + |
| 226 | +# Get chart JS from Python API |
| 227 | +chart_js = chart.to_js_literal() |
| 228 | + |
| 229 | +# Custom pitch rendering via Highcharts renderer API (injected as load event) |
| 230 | +arrows_json = json.dumps(arrows) |
| 231 | + |
| 232 | +pitch_load_js = """ |
| 233 | +(function() { |
| 234 | + var arrowData = ARROWS_DATA; |
| 235 | + var origChart = Highcharts.chart; |
| 236 | + Highcharts.chart = function(container, opts) { |
| 237 | + opts.chart = opts.chart || {}; |
| 238 | + opts.chart.events = opts.chart.events || {}; |
| 239 | + var origLoad = opts.chart.events.load; |
| 240 | + opts.chart.events.load = function() { |
| 241 | + if (origLoad) origLoad.call(this); |
| 242 | + var r = this.renderer; |
| 243 | + var xA = this.xAxis[0]; |
| 244 | + var yA = this.yAxis[0]; |
| 245 | +
|
| 246 | + function px(v) { return xA.toPixels(v); } |
| 247 | + function py(v) { return yA.toPixels(v); } |
| 248 | +
|
| 249 | + var la = {stroke: "rgba(255,255,255,0.82)", "stroke-width": 4, fill: "none"}; |
| 250 | +
|
| 251 | + // Pitch outline |
| 252 | + r.rect(px(0), py(68), px(105)-px(0), py(0)-py(68)).attr(la).add(); |
| 253 | +
|
| 254 | + // Halfway line |
| 255 | + r.path(["M", px(52.5), py(0), "L", px(52.5), py(68)]).attr(la).add(); |
| 256 | +
|
| 257 | + // Center circle |
| 258 | + var ccPts = []; |
| 259 | + for (var a = 0; a <= 360; a += 3) { |
| 260 | + var rad = a * Math.PI / 180; |
| 261 | + ccPts.push(a === 0 ? "M" : "L"); |
| 262 | + ccPts.push(px(52.5 + 9.15 * Math.cos(rad))); |
| 263 | + ccPts.push(py(34 + 9.15 * Math.sin(rad))); |
| 264 | + } |
| 265 | + r.path(ccPts).attr(la).add(); |
| 266 | +
|
| 267 | + // Center spot |
| 268 | + r.circle(px(52.5), py(34), 7).attr({fill: "rgba(255,255,255,0.82)"}).add(); |
| 269 | +
|
| 270 | + // Penalty areas |
| 271 | + r.rect(px(0), py(54.16), px(16.5)-px(0), py(13.84)-py(54.16)).attr(la).add(); |
| 272 | + r.rect(px(88.5), py(54.16), px(105)-px(88.5), py(13.84)-py(54.16)).attr(la).add(); |
| 273 | +
|
| 274 | + // Goal areas |
| 275 | + r.rect(px(0), py(43.16), px(5.5)-px(0), py(24.84)-py(43.16)).attr(la).add(); |
| 276 | + r.rect(px(99.5), py(43.16), px(105)-px(99.5), py(24.84)-py(43.16)).attr(la).add(); |
| 277 | +
|
| 278 | + // Penalty spots |
| 279 | + r.circle(px(11), py(34), 7).attr({fill: "rgba(255,255,255,0.82)"}).add(); |
| 280 | + r.circle(px(94), py(34), 7).attr({fill: "rgba(255,255,255,0.82)"}).add(); |
| 281 | +
|
| 282 | + // Left penalty arc |
| 283 | + var laPts = []; |
| 284 | + for (var a = -53; a <= 53; a += 2) { |
| 285 | + var rad = a * Math.PI / 180; |
| 286 | + laPts.push(a === -53 ? "M" : "L"); |
| 287 | + laPts.push(px(11 + 9.15 * Math.cos(rad))); |
| 288 | + laPts.push(py(34 + 9.15 * Math.sin(rad))); |
| 289 | + } |
| 290 | + r.path(laPts).attr(la).add(); |
| 291 | +
|
| 292 | + // Right penalty arc |
| 293 | + var raPts = []; |
| 294 | + for (var a = 127; a <= 233; a += 2) { |
| 295 | + var rad = a * Math.PI / 180; |
| 296 | + raPts.push(a === 127 ? "M" : "L"); |
| 297 | + raPts.push(px(94 + 9.15 * Math.cos(rad))); |
| 298 | + raPts.push(py(34 + 9.15 * Math.sin(rad))); |
| 299 | + } |
| 300 | + r.path(raPts).attr(la).add(); |
| 301 | +
|
| 302 | + // Corner arcs |
| 303 | + [[0,0,0,90],[105,0,90,180],[105,68,180,270],[0,68,270,360]].forEach(function(c) { |
| 304 | + var pts = []; |
| 305 | + for (var a = c[2]; a <= c[3]; a += 5) { |
| 306 | + var rad = a * Math.PI / 180; |
| 307 | + pts.push(a === c[2] ? "M" : "L"); |
| 308 | + pts.push(px(c[0] + Math.cos(rad))); |
| 309 | + pts.push(py(c[1] + Math.sin(rad))); |
| 310 | + } |
| 311 | + r.path(pts).attr(la).add(); |
| 312 | + }); |
| 313 | +
|
| 314 | + // Goal outlines |
| 315 | + var goalLa = {stroke: "rgba(255,255,255,0.55)", "stroke-width": 3, fill: "none"}; |
| 316 | + r.rect(px(-2.44), py(37.66), px(0)-px(-2.44), py(30.34)-py(37.66)).attr(goalLa).add(); |
| 317 | + r.rect(px(105), py(37.66), px(107.44)-px(105), py(30.34)-py(37.66)).attr(goalLa).add(); |
| 318 | +
|
| 319 | + // Directional arrows |
| 320 | + arrowData.forEach(function(a) { |
| 321 | + var x1 = px(a.x1), y1 = py(a.y1); |
| 322 | + var x2 = px(a.x2), y2 = py(a.y2); |
| 323 | + var alpha = a.ok ? 0.45 : 0.15; |
| 324 | + var cr = parseInt(a.c.slice(1,3), 16); |
| 325 | + var cg = parseInt(a.c.slice(3,5), 16); |
| 326 | + var cb = parseInt(a.c.slice(5,7), 16); |
| 327 | + var sc = "rgba(" + cr + "," + cg + "," + cb + "," + alpha + ")"; |
| 328 | + var sw = a.ok ? 2.5 : 1.5; |
| 329 | +
|
| 330 | + r.path(["M", x1, y1, "L", x2, y2]) |
| 331 | + .attr({stroke: sc, "stroke-width": sw}).add(); |
| 332 | +
|
| 333 | + var angle = Math.atan2(y2 - y1, x2 - x1); |
| 334 | + var aLen = a.ok ? 18 : 12; |
| 335 | + var hx1 = x2 - aLen * Math.cos(angle - 0.4); |
| 336 | + var hy1 = y2 - aLen * Math.sin(angle - 0.4); |
| 337 | + var hx2 = x2 - aLen * Math.cos(angle + 0.4); |
| 338 | + var hy2 = y2 - aLen * Math.sin(angle + 0.4); |
| 339 | + r.path(["M", hx1, hy1, "L", x2, y2, "L", hx2, hy2]) |
| 340 | + .attr({stroke: sc, "stroke-width": sw}).add(); |
| 341 | + }); |
| 342 | +
|
| 343 | + // Zone labels |
| 344 | + var zoneStyle = {color: "rgba(255,255,255,0.4)", fontSize: "28px", fontWeight: "bold", fontStyle: "italic"}; |
| 345 | + r.text("DEFENSIVE THIRD", px(17.5), py(-0.5)).attr({align: "center"}).css(zoneStyle).add(); |
| 346 | + r.text("MIDDLE THIRD", px(52.5), py(-0.5)).attr({align: "center"}).css(zoneStyle).add(); |
| 347 | + r.text("ATTACKING THIRD", px(87.5), py(-0.5)).attr({align: "center"}).css(zoneStyle).add(); |
| 348 | + }; |
| 349 | + return origChart.call(this, container, opts); |
| 350 | + }; |
| 351 | +})(); |
| 352 | +""".replace("ARROWS_DATA", arrows_json) |
| 353 | + |
| 354 | +html_content = ( |
| 355 | + '<!DOCTYPE html>\n<html>\n<head>\n<meta charset="utf-8">\n' |
| 356 | + "<script>" + highcharts_js + "</script>\n" |
| 357 | + "</head>\n" |
| 358 | + '<body style="margin:0;background:#1a1a2e;">\n' |
| 359 | + '<div id="container" style="width:4800px;height:2700px;"></div>\n' |
| 360 | + "<script>" + pitch_load_js + "</script>\n" |
| 361 | + "<script>" + chart_js + "</script>\n" |
| 362 | + "</body>\n</html>" |
| 363 | +) |
| 364 | + |
| 365 | +# Save interactive HTML |
| 366 | +with open("plot.html", "w", encoding="utf-8") as f: |
| 367 | + f.write(html_content) |
| 368 | + |
| 369 | +# Write temp file for screenshot |
| 370 | +with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, encoding="utf-8") as f: |
| 371 | + f.write(html_content) |
| 372 | + temp_path = f.name |
| 373 | + |
| 374 | +# Screenshot with headless Chrome |
| 375 | +chrome_options = Options() |
| 376 | +chrome_options.add_argument("--headless") |
| 377 | +chrome_options.add_argument("--no-sandbox") |
| 378 | +chrome_options.add_argument("--disable-dev-shm-usage") |
| 379 | +chrome_options.add_argument("--disable-gpu") |
| 380 | +chrome_options.add_argument("--window-size=4800,2700") |
| 381 | + |
| 382 | +driver = webdriver.Chrome(options=chrome_options) |
| 383 | +driver.get(f"file://{temp_path}") |
| 384 | +time.sleep(5) |
| 385 | +driver.save_screenshot("plot.png") |
| 386 | +driver.quit() |
| 387 | + |
| 388 | +Path(temp_path).unlink() |
0 commit comments