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