{"spec_id":"scatter-shot-chart","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nscatter-shot-chart: Basketball Shot Chart\nLibrary: bokeh 3.9.1 | Python 3.13.14\nQuality: 86/100 | Updated: 2026-06-21\n\"\"\"\n\nimport os\nimport sys\n\n\n# Prevent self-import: this file is named bokeh.py, which shadows the installed\n# bokeh package when its directory sits at the front of sys.path.\n_this_dir = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.abspath(p or \".\") != _this_dir]\n\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import ColumnDataSource, HoverTool, Label, Legend, LegendItem, Range1d\nfrom bokeh.plotting import figure\nfrom bokeh.resources import CDN\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\n# Theme tokens — Imprint palette / anyplot style guide\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint palette — semantic exception: made=good/pass→green, missed=bad/fail→red\nMADE_COLOR = \"#009E73\"  # Imprint position 1 (brand green, semantic: good/pass)\nMISSED_COLOR = \"#AE3030\"  # Imprint position 5 (matte red, semantic: bad/fail)\n\n# Court surface — distinct from page background for both themes\nCOURT_FLOOR = \"#EDE8DC\" if THEME == \"light\" else \"#242420\"\nCOURT_LINE = INK_SOFT  # theme-adaptive court lines\n\n# Data\nnp.random.seed(42)\nn_shots = 350\n\nx = np.zeros(n_shots)\ny = np.zeros(n_shots)\nmade = np.zeros(n_shots, dtype=bool)\nshot_type = []\nzone_label = []\n\nfor i in range(n_shots):\n    zone = np.random.choice([\"paint\", \"midrange\", \"three\", \"corner3\", \"ft\"], p=[0.25, 0.20, 0.30, 0.10, 0.15])\n    if zone == \"paint\":\n        x[i] = np.random.uniform(-8, 8)\n        y[i] = np.random.uniform(0, 12)\n        made[i] = np.random.random() < 0.55\n        shot_type.append(\"2-pointer\")\n        zone_label.append(\"Paint\")\n    elif zone == \"midrange\":\n        x[i] = np.random.uniform(-16, 16)\n        y[i] = np.random.uniform(5, 20)\n        dist = np.sqrt(x[i] ** 2 + y[i] ** 2)\n        while dist > 23.0 or dist < 5:\n            x[i] = np.random.uniform(-16, 16)\n            y[i] = np.random.uniform(5, 20)\n            dist = np.sqrt(x[i] ** 2 + y[i] ** 2)\n        made[i] = np.random.random() < 0.42\n        shot_type.append(\"2-pointer\")\n        zone_label.append(\"Mid-Range\")\n    elif zone == \"three\":\n        angle = np.random.uniform(0.25, np.pi - 0.25)\n        r = np.random.uniform(24, 28)\n        x[i] = r * np.cos(angle)\n        y[i] = r * np.sin(angle)\n        x[i] = np.clip(x[i], -24, 24)\n        y[i] = np.clip(y[i], 10, 33)\n        made[i] = np.random.random() < 0.36\n        shot_type.append(\"3-pointer\")\n        zone_label.append(\"Three-Point\")\n    elif zone == \"corner3\":\n        side = np.random.choice([-1, 1])\n        x[i] = side * np.random.uniform(21.5, 23)\n        y[i] = np.random.uniform(0, 10)\n        made[i] = np.random.random() < 0.39\n        shot_type.append(\"3-pointer\")\n        zone_label.append(\"Corner 3\")\n    else:\n        x[i] = np.random.uniform(-1.5, 1.5)\n        y[i] = np.random.uniform(13.5, 16.5)\n        made[i] = np.random.random() < 0.78\n        shot_type.append(\"free-throw\")\n        zone_label.append(\"Free Throw\")\n\nshot_type = np.array(shot_type)\nzone_label = np.array(zone_label)\n\n# Zone efficiency stats for data storytelling\nzones = [\"Paint\", \"Mid-Range\", \"Three-Point\", \"Corner 3\", \"Free Throw\"]\nzone_stats = {}\nfor z in zones:\n    mask = zone_label == z\n    z_made = int(np.sum(made[mask]))\n    z_total = int(np.sum(mask))\n    z_pct = z_made / z_total * 100 if z_total > 0 else 0\n    zone_stats[z] = (z_made, z_total, z_pct)\n\n# Canvas — 2400×2400 (square, canonical for symmetric shot chart).\n# y_range (-12, 42) = 54 units, matching x_range = 54 units so match_aspect=True\n# fills the square canvas uniformly. min_border_top trimmed to reduce chrome overhead.\ntitle = \"scatter-shot-chart · python · bokeh · anyplot.ai\"\np = figure(\n    width=2400,\n    height=2400,\n    title=title,\n    x_range=Range1d(-27, 27),\n    y_range=Range1d(-12, 42),\n    toolbar_location=None,\n    match_aspect=True,\n    min_border_bottom=50,\n    min_border_left=60,\n    min_border_top=100,\n    min_border_right=60,\n)\n\n# Court floor — covers the full visible data range\np.rect(x=0, y=15, width=60, height=60, fill_color=COURT_FLOOR, line_color=None)\n\n# Baseline and sidelines (half-court)\np.line([-25, 25], [0, 0], line_color=COURT_LINE, line_width=5)\np.line([-25, -25], [0, 35], line_color=COURT_LINE, line_width=3)\np.line([25, 25], [0, 35], line_color=COURT_LINE, line_width=3)\n\n# Paint / key area (16 ft wide, 19 ft deep from baseline)\np.line([-8, -8, 8, 8], [0, 19, 19, 0], line_color=COURT_LINE, line_width=3)\n\n# Free-throw circle (solid top half, dashed bottom half)\ntheta_top = np.linspace(0, np.pi, 100)\ntheta_bot = np.linspace(np.pi, 2 * np.pi, 100)\np.line(6 * np.cos(theta_top), 19 + 6 * np.sin(theta_top), line_color=COURT_LINE, line_width=3)\np.line(6 * np.cos(theta_bot), 19 + 6 * np.sin(theta_bot), line_color=COURT_LINE, line_width=2, line_dash=\"dashed\")\n\n# Restricted area arc (4 ft radius from basket)\ntheta_ra = np.linspace(0, np.pi, 100)\np.line(4 * np.cos(theta_ra), 4 * np.sin(theta_ra), line_color=COURT_LINE, line_width=2)\n\n# Three-point arc (23.75 ft at top, 22 ft in corners)\ntheta_3pt = np.linspace(np.arccos(22.0 / 23.75), np.pi - np.arccos(22.0 / 23.75), 200)\np.line(23.75 * np.cos(theta_3pt), 23.75 * np.sin(theta_3pt), line_color=COURT_LINE, line_width=3)\n\n# Corner three-point lines (22 ft from basket, straight to baseline)\ncorner_y = 23.75 * np.sin(np.arccos(22.0 / 23.75))\np.line([-22, -22], [0, corner_y], line_color=COURT_LINE, line_width=3)\np.line([22, 22], [0, corner_y], line_color=COURT_LINE, line_width=3)\n\n# Basket (hoop at ~1.5 ft from backboard)\nhoop_theta = np.linspace(0, 2 * np.pi, 50)\np.line(0.75 * np.cos(hoop_theta), 0.75 * np.sin(hoop_theta) + 1.5, line_color=\"#C44E2B\", line_width=5)\np.line([-3, 3], [0, 0], line_color=INK_SOFT, line_width=6)\n\n# Shot data sources\nmade_mask = made\nmissed_mask = ~made\nresult_label = np.where(made, \"Made\", \"Missed\")\ndistance = np.round(np.sqrt(x**2 + y**2), 1)\n\nsource_made = ColumnDataSource(\n    data={\n        \"x\": x[made_mask],\n        \"y\": y[made_mask],\n        \"result\": result_label[made_mask],\n        \"zone\": zone_label[made_mask],\n        \"shot_type\": shot_type[made_mask],\n        \"distance\": distance[made_mask],\n    }\n)\nsource_missed = ColumnDataSource(\n    data={\n        \"x\": x[missed_mask],\n        \"y\": y[missed_mask],\n        \"result\": result_label[missed_mask],\n        \"zone\": zone_label[missed_mask],\n        \"shot_type\": shot_type[missed_mask],\n        \"distance\": distance[missed_mask],\n    }\n)\n\nr_made = p.scatter(\n    x=\"x\",\n    y=\"y\",\n    source=source_made,\n    size=16,\n    fill_color=MADE_COLOR,\n    fill_alpha=0.60,\n    line_color=PAGE_BG,\n    line_width=1.2,\n    marker=\"circle\",\n)\nr_missed = p.scatter(\n    x=\"x\",\n    y=\"y\",\n    source=source_missed,\n    size=16,\n    fill_color=None,\n    fill_alpha=0,\n    line_color=MISSED_COLOR,\n    line_width=3.5,\n    marker=\"x\",\n)\n\n# HoverTool — Bokeh's signature interactive feature\nhover = HoverTool(\n    renderers=[r_made, r_missed],\n    tooltips=[(\"Result\", \"@result\"), (\"Zone\", \"@zone\"), (\"Shot Type\", \"@shot_type\"), (\"Distance\", \"@distance ft\")],\n    point_policy=\"snap_to_data\",\n)\np.add_tools(hover)\n\n# Legend (horizontal, above the plot area)\nn_made = int(np.sum(made))\nn_missed = int(np.sum(~made))\nlegend = Legend(\n    items=[\n        LegendItem(label=f\"Made ({n_made})\", renderers=[r_made]),\n        LegendItem(label=f\"Missed ({n_missed})\", renderers=[r_missed]),\n    ],\n    location=\"top_center\",\n    orientation=\"horizontal\",\n)\np.add_layout(legend, \"above\")\np.legend.label_text_font_size = \"28pt\"\np.legend.label_text_color = INK_SOFT\np.legend.glyph_width = 40\np.legend.glyph_height = 40\np.legend.spacing = 50\np.legend.padding = 20\np.legend.background_fill_alpha = 0.0\np.legend.border_line_color = None\n\n# FG% summary — placed above the three-point arc in the open court floor area\nfg_pct = n_made / n_shots * 100\np.add_layout(\n    Label(\n        x=0,\n        y=32,\n        text=f\"FG: {fg_pct:.1f}%  ·  {n_shots} attempts\",\n        text_font_size=\"24pt\",\n        text_color=INK_MUTED,\n        text_align=\"center\",\n        text_font_style=\"bold\",\n    )\n)\n\n# Zone efficiency overlays with made/total counts\nzone_positions = {\n    \"Paint\": [(3, 6)],\n    \"Mid-Range\": [(15, 14)],\n    \"Three-Point\": [(0, 27)],\n    \"Corner 3\": [(-21, 4), (21, 4)],\n    \"Free Throw\": [(18, 16)],\n}\nfor z, positions in zone_positions.items():\n    z_made, z_total, z_pct = zone_stats[z]\n    for zx, zy in positions:\n        p.add_layout(\n            Label(\n                x=zx,\n                y=zy,\n                text=f\"{z_pct:.0f}%\",\n                text_font_size=\"22pt\",\n                text_color=INK,\n                text_align=\"center\",\n                text_font_style=\"bold\",\n                background_fill_color=ELEVATED_BG,\n                background_fill_alpha=0.85,\n            )\n        )\n        p.add_layout(\n            Label(\n                x=zx,\n                y=zy - 2.0,\n                text=f\"{z_made}/{z_total}\",\n                text_font_size=\"18pt\",\n                text_color=INK_SOFT,\n                text_align=\"center\",\n                background_fill_color=ELEVATED_BG,\n                background_fill_alpha=0.85,\n            )\n        )\n\n# Chrome — theme-adaptive\np.title.text_font_size = \"50pt\"\np.title.text_color = INK\np.title.text_font_style = \"bold\"\np.title.align = \"center\"\n\np.xaxis.visible = False\np.yaxis.visible = False\np.xgrid.grid_line_color = None\np.ygrid.grid_line_color = None\n\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = None\n\n# Save — interactive HTML, then PNG via Selenium headless Chrome\noutput_file(f\"plot-{THEME}.html\")\nsave(p, resources=CDN)\n\nW, H = 2400, 2400\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\n# CDP override forces exact W×H viewport regardless of browser chrome\ndriver.execute_cdp_cmd(\n    \"Emulation.setDeviceMetricsOverride\", {\"width\": W, \"height\": H, \"deviceScaleFactor\": 1, \"mobile\": False}\n)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}