Skip to content

Commit 920564c

Browse files
feat(highcharts): implement scatter-shot-chart (#5117)
## Implementation: `scatter-shot-chart` - highcharts Implements the **highcharts** version of `scatter-shot-chart`. **File:** `plots/scatter-shot-chart/implementations/highcharts.py` **Parent Issue:** #4416 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/23361192951)* --------- 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 9396b44 commit 920564c

2 files changed

Lines changed: 562 additions & 0 deletions

File tree

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
""" pyplots.ai
2+
scatter-shot-chart: Basketball Shot Chart
3+
Library: highcharts unknown | Python 3.14.3
4+
Quality: 88/100 | Created: 2026-03-20
5+
"""
6+
7+
import tempfile
8+
import time
9+
import urllib.request
10+
from pathlib import Path
11+
12+
import numpy as np
13+
from highcharts_core.chart import Chart
14+
from highcharts_core.options import HighchartsOptions
15+
from highcharts_core.options.series.scatter import ScatterSeries
16+
from selenium import webdriver
17+
from selenium.webdriver.chrome.options import Options
18+
19+
20+
# Data - Synthetic basketball shot chart data
21+
np.random.seed(42)
22+
23+
# NBA half-court: basket at (0,0), baseline at y=-5.25, half-court at y=41.75
24+
# Court is 50 ft wide (x: -25 to 25)
25+
# Basket center is 5.25 ft from baseline
26+
27+
# Generate shot data by zone using a compact zone definition table
28+
shots = []
29+
# Zone definitions: (n, angle_range, dist_range, make_pct, shot_type, use_polar)
30+
zones = [
31+
(80, (0, np.pi), (0, 8), 0.55, "2-pointer", True), # Paint area
32+
(100, (0.1, np.pi - 0.1), (8, 22), 0.40, "2-pointer", True), # Mid-range
33+
(120, (0.05, np.pi - 0.05), (23, 27), 0.35, "3-pointer", True), # Arc threes
34+
]
35+
for n, (a_lo, a_hi), (d_lo, d_hi), pct, stype, _ in zones:
36+
angles = np.random.uniform(a_lo, a_hi, n)
37+
dists = np.random.uniform(d_lo, d_hi, n)
38+
signs = np.where(np.random.random(n) > 0.5, 1, -1)
39+
xs, ys = dists * np.cos(angles) * signs, dists * np.sin(angles)
40+
made = np.random.random(n) < pct
41+
for i in range(n):
42+
shots.append({"x": float(xs[i]), "y": float(ys[i]), "made": bool(made[i]), "type": stype})
43+
44+
# Corner threes (rectangular distribution)
45+
n_corner = 40
46+
corner_signs = np.where(np.random.random(n_corner) > 0.5, 1, -1)
47+
corner_x = corner_signs * np.random.uniform(20, 22, n_corner)
48+
corner_y = np.random.uniform(-1, 8, n_corner)
49+
corner_made = np.random.random(n_corner) < 0.38
50+
for i in range(n_corner):
51+
shots.append({"x": float(corner_x[i]), "y": float(corner_y[i]), "made": bool(corner_made[i]), "type": "3-pointer"})
52+
53+
# Free throws (clustered at FT line)
54+
n_ft = 30
55+
ft_x = np.random.normal(0, 0.3, n_ft)
56+
ft_y = 14.0 + np.random.normal(0, 0.2, n_ft)
57+
ft_made = np.random.random(n_ft) < 0.80
58+
for i in range(n_ft):
59+
shots.append({"x": float(ft_x[i]), "y": float(ft_y[i]), "made": bool(ft_made[i]), "type": "free-throw"})
60+
61+
# Build series data: made vs missed
62+
made_2pt = [{"x": round(s["x"], 1), "y": round(s["y"], 1)} for s in shots if s["made"] and s["type"] == "2-pointer"]
63+
missed_2pt = [
64+
{"x": round(s["x"], 1), "y": round(s["y"], 1)} for s in shots if not s["made"] and s["type"] == "2-pointer"
65+
]
66+
made_3pt = [{"x": round(s["x"], 1), "y": round(s["y"], 1)} for s in shots if s["made"] and s["type"] == "3-pointer"]
67+
missed_3pt = [
68+
{"x": round(s["x"], 1), "y": round(s["y"], 1)} for s in shots if not s["made"] and s["type"] == "3-pointer"
69+
]
70+
made_ft = [{"x": round(s["x"], 1), "y": round(s["y"], 1)} for s in shots if s["made"] and s["type"] == "free-throw"]
71+
missed_ft = [
72+
{"x": round(s["x"], 1), "y": round(s["y"], 1)} for s in shots if not s["made"] and s["type"] == "free-throw"
73+
]
74+
75+
# Chart setup
76+
chart = Chart(container="container")
77+
chart.options = HighchartsOptions()
78+
79+
chart.options.chart = {
80+
"type": "scatter",
81+
"width": 3600,
82+
"height": 3600,
83+
"backgroundColor": "#1a1a2e",
84+
"plotBackgroundColor": "#2a2a3e",
85+
"marginBottom": 120,
86+
"marginTop": 160,
87+
"marginLeft": 120,
88+
"marginRight": 120,
89+
"style": {"fontFamily": "'Segoe UI', Helvetica, Arial, sans-serif"},
90+
}
91+
92+
chart.options.title = {
93+
"text": "scatter-shot-chart \u00b7 highcharts \u00b7 pyplots.ai",
94+
"style": {"fontSize": "42px", "fontWeight": "600", "color": "#e8e8e8"},
95+
}
96+
97+
chart.options.subtitle = {
98+
"text": ('<span style="font-size:28px;color:#aaa;">Season Shot Chart \u2014 370 Attempts</span>'),
99+
"useHTML": True,
100+
}
101+
102+
chart.options.x_axis = {
103+
"min": -28,
104+
"max": 28,
105+
"title": {"enabled": False},
106+
"labels": {"enabled": False},
107+
"gridLineWidth": 0,
108+
"lineWidth": 0,
109+
"tickWidth": 0,
110+
}
111+
112+
chart.options.y_axis = {
113+
"min": -8,
114+
"max": 30,
115+
"title": {"enabled": False},
116+
"labels": {"enabled": False},
117+
"gridLineWidth": 0,
118+
"lineWidth": 0,
119+
"tickWidth": 0,
120+
}
121+
122+
chart.options.legend = {
123+
"enabled": True,
124+
"floating": True,
125+
"verticalAlign": "top",
126+
"align": "right",
127+
"x": -30,
128+
"y": 80,
129+
"layout": "vertical",
130+
"itemStyle": {"fontSize": "28px", "fontWeight": "normal", "color": "#ddd"},
131+
"itemHoverStyle": {"color": "#fff"},
132+
"symbolRadius": 6,
133+
"symbolWidth": 22,
134+
"symbolHeight": 22,
135+
"itemMarginBottom": 8,
136+
"backgroundColor": "rgba(26,26,46,0.9)",
137+
"borderRadius": 10,
138+
"padding": 18,
139+
}
140+
141+
chart.options.credits = {"enabled": False}
142+
143+
chart.options.tooltip = {
144+
"headerFormat": "",
145+
"pointFormat": (
146+
'<b style="color:{series.color}">{series.name}</b><br/>Position: ({point.x:.1f} ft, {point.y:.1f} ft)'
147+
),
148+
"style": {"fontSize": "20px"},
149+
"backgroundColor": "rgba(26,26,46,0.92)",
150+
"borderColor": "#555",
151+
}
152+
153+
chart.options.plot_options = {"scatter": {"shadow": False, "states": {"hover": {"enabled": True}}}}
154+
155+
# Series definitions
156+
# Colorblind-safe palette: blue (#4A90D9) for made, orange (#E8833A) for missed
157+
MADE_COLOR = "#4A90D9"
158+
MISSED_COLOR = "#E8833A"
159+
series_defs = [
160+
{"name": "Made 2PT", "data": made_2pt, "color": MADE_COLOR, "symbol": "circle", "radius": 10},
161+
{"name": "Missed 2PT", "data": missed_2pt, "color": MISSED_COLOR, "symbol": "circle", "radius": 8},
162+
{"name": "Made 3PT", "data": made_3pt, "color": MADE_COLOR, "symbol": "diamond", "radius": 11},
163+
{"name": "Missed 3PT", "data": missed_3pt, "color": MISSED_COLOR, "symbol": "diamond", "radius": 9},
164+
{"name": "Made FT", "data": made_ft, "color": MADE_COLOR, "symbol": "square", "radius": 9},
165+
{"name": "Missed FT", "data": missed_ft, "color": MISSED_COLOR, "symbol": "square", "radius": 7},
166+
]
167+
168+
for sdef in series_defs:
169+
series = ScatterSeries()
170+
series.name = sdef["name"]
171+
series.color = sdef["color"]
172+
series.data = sdef["data"]
173+
is_made = "Made" in sdef["name"]
174+
series.marker = {
175+
"symbol": sdef["symbol"],
176+
"radius": sdef["radius"],
177+
"lineColor": "#ffffff",
178+
"lineWidth": 1 if is_made else 2,
179+
"fillColor": sdef["color"] if is_made else "rgba(232,131,58,0.45)",
180+
}
181+
series.z_index = 6 if is_made else 5
182+
chart.add_series(series)
183+
184+
# Download Highcharts JS
185+
cdn_urls = ["https://code.highcharts.com/highcharts.js", "https://cdn.jsdelivr.net/npm/highcharts@11/highcharts.js"]
186+
highcharts_js = None
187+
for url in cdn_urls:
188+
try:
189+
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
190+
with urllib.request.urlopen(req, timeout=30) as response:
191+
highcharts_js = response.read().decode("utf-8")
192+
break
193+
except Exception:
194+
continue
195+
196+
chart_js = chart.to_js_literal()
197+
198+
# Court drawing via Highcharts renderer API
199+
court_js = """
200+
(function() {
201+
var origChart = Highcharts.chart;
202+
Highcharts.chart = function(container, opts) {
203+
opts.chart = opts.chart || {};
204+
opts.chart.events = opts.chart.events || {};
205+
var origLoad = opts.chart.events.load;
206+
opts.chart.events.load = function() {
207+
if (origLoad) origLoad.call(this);
208+
var r = this.renderer;
209+
var xA = this.xAxis[0];
210+
var yA = this.yAxis[0];
211+
212+
function px(v) { return xA.toPixels(v); }
213+
function py(v) { return yA.toPixels(v); }
214+
215+
var la = {stroke: "rgba(255,255,255,0.55)", "stroke-width": 2.5, fill: "none"};
216+
var laPrimary = {stroke: "rgba(255,255,255,0.75)", "stroke-width": 4, fill: "none"};
217+
218+
// Court outline (half court: 50ft wide, baseline to beyond 3pt arc)
219+
r.rect(px(-25), py(30), px(25)-px(-25), py(-5.25)-py(30)).attr(
220+
{stroke: "rgba(255,255,255,0.8)", "stroke-width": 5, fill: "none"}
221+
).add();
222+
223+
// Half-court line
224+
r.path(["M", px(-25), py(30), "L", px(25), py(30)]).attr(
225+
{stroke: "rgba(255,255,255,0.5)", "stroke-width": 3, dashstyle: "dash"}
226+
).add();
227+
228+
// Paint / key area (16 ft wide, 19 ft from baseline)
229+
r.rect(px(-8), py(13.75), px(8)-px(-8), py(-5.25)-py(13.75)).attr(la).add();
230+
231+
// Free-throw circle (6 ft radius at 13.75 ft from baseline)
232+
var ftPts = [];
233+
for (var a = 0; a <= 180; a += 3) {
234+
var rad = a * Math.PI / 180;
235+
ftPts.push(a === 0 ? "M" : "L");
236+
ftPts.push(px(6 * Math.cos(rad)));
237+
ftPts.push(py(13.75 + 6 * Math.sin(rad)));
238+
}
239+
r.path(ftPts).attr(la).add();
240+
241+
// Free-throw circle bottom (dashed)
242+
var ftBPts = [];
243+
for (var a = 180; a <= 360; a += 3) {
244+
var rad = a * Math.PI / 180;
245+
ftBPts.push(a === 180 ? "M" : "L");
246+
ftBPts.push(px(6 * Math.cos(rad)));
247+
ftBPts.push(py(13.75 + 6 * Math.sin(rad)));
248+
}
249+
r.path(ftBPts).attr(
250+
{stroke: "rgba(255,255,255,0.35)", "stroke-width": 2, fill: "none", dashstyle: "dash"}
251+
).add();
252+
253+
// Restricted area arc (4 ft radius)
254+
var raPts = [];
255+
for (var a = 0; a <= 180; a += 3) {
256+
var rad = a * Math.PI / 180;
257+
raPts.push(a === 0 ? "M" : "L");
258+
raPts.push(px(4 * Math.cos(rad)));
259+
raPts.push(py(4 * Math.sin(rad)));
260+
}
261+
r.path(raPts).attr(la).add();
262+
263+
// Three-point line
264+
// Corner straight sections: from baseline to where arc begins
265+
// Arc radius 23.75 ft, straight at x = +-22
266+
var arcStartY = Math.sqrt(23.75*23.75 - 22*22);
267+
268+
// Left corner straight
269+
r.path(["M", px(-22), py(-5.25), "L", px(-22), py(arcStartY)]).attr(laPrimary).add();
270+
// Right corner straight
271+
r.path(["M", px(22), py(-5.25), "L", px(22), py(arcStartY)]).attr(laPrimary).add();
272+
273+
// Three-point arc
274+
var tpPts = [];
275+
var startAngle = Math.acos(22/23.75);
276+
var endAngle = Math.PI - startAngle;
277+
for (var a = startAngle; a <= endAngle; a += 0.02) {
278+
tpPts.push(tpPts.length === 0 ? "M" : "L");
279+
tpPts.push(px(23.75 * Math.cos(a)));
280+
tpPts.push(py(23.75 * Math.sin(a)));
281+
}
282+
r.path(tpPts).attr(laPrimary).add();
283+
284+
// Basket (rim circle, ~0.75 ft radius)
285+
r.circle(px(0), py(0), 8).attr(
286+
{stroke: "#ff6b35", "stroke-width": 4, fill: "none"}
287+
).add();
288+
289+
// Backboard (4 ft wide, at y ~ -1.25)
290+
r.path(["M", px(-3), py(-1.25), "L", px(3), py(-1.25)]).attr(
291+
{stroke: "rgba(255,255,255,0.8)", "stroke-width": 4}
292+
).add();
293+
294+
// Baseline text
295+
var zoneStyle = {color: "rgba(255,255,255,0.3)", fontSize: "24px", fontWeight: "bold", fontStyle: "italic"};
296+
r.text("BASELINE", px(0), py(-6.5)).attr({align: "center"}).css(zoneStyle).add();
297+
};
298+
return origChart.call(this, container, opts);
299+
};
300+
})();
301+
"""
302+
303+
html_content = (
304+
'<!DOCTYPE html>\n<html>\n<head>\n<meta charset="utf-8">\n'
305+
"<script>" + highcharts_js + "</script>\n"
306+
"</head>\n"
307+
'<body style="margin:0;background:#1a1a2e;">\n'
308+
'<div id="container" style="width:3600px;height:3600px;"></div>\n'
309+
"<script>" + court_js + "</script>\n"
310+
"<script>" + chart_js + "</script>\n"
311+
"</body>\n</html>"
312+
)
313+
314+
# Save interactive HTML
315+
with open("plot.html", "w", encoding="utf-8") as f:
316+
f.write(html_content)
317+
318+
# Write temp file for screenshot
319+
with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, encoding="utf-8") as f:
320+
f.write(html_content)
321+
temp_path = f.name
322+
323+
# Screenshot with headless Chrome
324+
chrome_options = Options()
325+
chrome_options.add_argument("--headless")
326+
chrome_options.add_argument("--no-sandbox")
327+
chrome_options.add_argument("--disable-dev-shm-usage")
328+
chrome_options.add_argument("--disable-gpu")
329+
chrome_options.add_argument("--window-size=3600,3600")
330+
331+
driver = webdriver.Chrome(options=chrome_options)
332+
driver.get(f"file://{temp_path}")
333+
time.sleep(5)
334+
driver.save_screenshot("plot.png")
335+
driver.quit()
336+
337+
Path(temp_path).unlink()

0 commit comments

Comments
 (0)