Skip to content

Commit 1dddd26

Browse files
feat(highcharts): implement scatter-pitch-events (#5104)
## Implementation: `scatter-pitch-events` - highcharts Implements the **highcharts** version of `scatter-pitch-events`. **File:** `plots/scatter-pitch-events/implementations/highcharts.py` **Parent Issue:** #4417 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/23342848945)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent d9a8afc commit 1dddd26

2 files changed

Lines changed: 624 additions & 0 deletions

File tree

Lines changed: 388 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,388 @@
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

Comments
 (0)