{"spec_id":"scatter-shot-chart","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nscatter-shot-chart: Basketball Shot Chart\nLibrary: pygal 3.1.3 | Python 3.13.14\nQuality: 86/100 | Updated: 2026-06-21\n\"\"\"\n\nimport math\nimport os\n\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\n\n\n# Theme tokens — Imprint style guide\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Court structural colors (non-categorical, contextual)\nCOURT_LINE = \"#B8A990\" if THEME == \"light\" else \"#7A6A5A\"\nCOURT_BOARD = \"#8C7E6E\" if THEME == \"light\" else \"#9A8A7A\"\nCOURT_RIM = \"#BD8233\"  # Imprint ochre — approximates real orange rim, visible in both themes\n\n# Imprint palette — semantic: made = good (green), missed = error (red)\nMADE_COLOR = \"#009E73\"  # Imprint position 1 — brand green\nMISSED_COLOR = \"#AE3030\"  # Imprint position 5 — matte red, semantic anchor for bad/loss/error\n\n# Data — synthetic NBA shot chart (200 attempts)\nnp.random.seed(42)\n\nn_shots = 200\nzones = np.random.choice([\"paint\", \"midrange\", \"three\", \"free_throw\"], n_shots, p=[0.30, 0.25, 0.35, 0.10])\n\nx = np.zeros(n_shots)\ny = np.zeros(n_shots)\nmade = np.zeros(n_shots, dtype=bool)\nshot_type = []\n\nfor i in range(n_shots):\n    if zones[i] == \"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    elif zones[i] == \"midrange\":\n        angle = np.random.uniform(0, math.pi)\n        r = np.random.uniform(10, 22)\n        x[i] = r * math.cos(angle)\n        y[i] = r * math.sin(angle)\n        made[i] = np.random.random() < 0.40\n        shot_type.append(\"2-pointer\")\n    elif zones[i] == \"three\":\n        angle = np.random.uniform(0.15, math.pi - 0.15)\n        r = np.random.uniform(23, 27)\n        x[i] = r * math.cos(angle)\n        y[i] = r * math.sin(angle)\n        # Corner threes\n        if np.random.random() < 0.25:\n            x[i] = np.random.choice([-22, 22]) + np.random.normal(0, 0.5)\n            y[i] = np.random.uniform(0, 8)\n        made[i] = np.random.random() < 0.36\n        shot_type.append(\"3-pointer\")\n    else:\n        x[i] = np.random.normal(0, 0.8)\n        y[i] = 15 + np.random.normal(0, 0.8)\n        made[i] = np.random.random() < 0.80\n        shot_type.append(\"free-throw\")\n\n# Court geometry — NBA half-court (origin at basket center)\n# Three-point arc: 23.75 ft radius, 22 ft corner lines to ~14 ft out\nthree_pt_angles = np.linspace(math.acos(22.0 / 23.75), math.pi - math.acos(22.0 / 23.75), 80)\nthree_pt_arc = [(23.75 * math.cos(a), 23.75 * math.sin(a)) for a in three_pt_angles]\nthree_pt_left_corner = [(-22, 0), (-22, 14)]\nthree_pt_right_corner = [(22, 0), (22, 14)]\n\n# Paint / key area: 16 ft wide, 19 ft deep (to free-throw line)\npaint = [(-8, 0), (-8, 19), (8, 19), (8, 0)]\n\n# Free-throw circle: 6 ft radius centered at free-throw line (top half solid, bottom dashed)\nft_circle_angles = np.linspace(0, math.pi, 40)\nft_circle_top = [(6 * math.cos(a), 19 + 6 * math.sin(a)) for a in ft_circle_angles]\nft_circle_bottom_angles = np.linspace(math.pi, 2 * math.pi, 40)\nft_circle_bottom = [(6 * math.cos(a), 19 + 6 * math.sin(a)) for a in ft_circle_bottom_angles]\n\n# Restricted area arc: 4 ft radius\nrestricted_angles = np.linspace(0, math.pi, 30)\nrestricted_arc = [(4 * math.cos(a), 4 * math.sin(a)) for a in restricted_angles]\n\n# Backboard and rim\nbackboard = [(-3, -0.5), (3, -0.5)]\nrim_angles = np.linspace(0, 2 * math.pi, 30)\nrim = [(0.75 * math.cos(a), 1.5 + 0.75 * math.sin(a)) for a in rim_angles]\n\nbaseline = [(-25, 0), (25, 0)]\n\n# FG% statistics by zone for legend labels\npaint_ft_mask = np.array([z in (\"paint\", \"free_throw\") for z in zones])\nperim_mask = ~paint_ft_mask\nn_inside = int(np.sum(paint_ft_mask))\nn_perim = int(np.sum(perim_mask))\ninside_fg = np.sum(made[paint_ft_mask]) / n_inside * 100\nperim_fg = np.sum(made[perim_mask]) / n_perim * 100\n\n# Style — colors tuple covers court lines (1–10) + shot series (11–14)\n# Court lines are structural/contextual, not categorical data\nfont = \"DejaVu Sans, Helvetica, Arial, sans-serif\"\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    guide_stroke_color=PAGE_BG,  # guides off, colour matches bg for safety\n    colors=(\n        COURT_LINE,  # 1: baseline\n        COURT_LINE,  # 2: three-point arc\n        COURT_LINE,  # 3: corner left\n        COURT_LINE,  # 4: corner right\n        COURT_LINE,  # 5: paint rectangle\n        COURT_LINE,  # 6: free-throw circle top\n        COURT_LINE,  # 7: free-throw circle bottom (dashed)\n        COURT_LINE,  # 8: restricted arc\n        COURT_BOARD,  # 9: backboard (darker)\n        COURT_RIM,  # 10: rim (Imprint ochre ≈ real orange)\n        MADE_COLOR,  # 11: Made Inside — Imprint green, large markers\n        MADE_COLOR,  # 12: Made Perimeter — Imprint green, smaller markers\n        MISSED_COLOR,  # 13: Missed Inside — Imprint red, large markers\n        MISSED_COLOR,  # 14: Missed Perimeter — Imprint red, smaller markers\n    ),\n    font_family=font,\n    title_font_family=font,\n    title_font_size=66,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    legend_font_family=font,\n    value_font_size=36,\n    tooltip_font_size=30,\n    tooltip_font_family=font,\n    opacity=0.82,\n    opacity_hover=1.0,\n)\n\ntitle = \"scatter-shot-chart · python · pygal · anyplot.ai\"\n\n# Chart — 2400×2400 square for 1:1 court aspect ratio\nchart = pygal.XY(\n    width=2400,\n    height=2400,\n    style=custom_style,\n    title=title,\n    show_legend=True,\n    legend_at_bottom=True,\n    legend_at_bottom_columns=2,\n    legend_box_size=36,\n    stroke=True,\n    dots_size=0,\n    show_x_guides=False,\n    show_y_guides=False,\n    show_x_labels=False,\n    show_y_labels=False,\n    xrange=(-27, 27),\n    range=(-2, 28),\n    margin_bottom=120,\n    margin_left=20,\n    margin_right=20,\n    margin_top=60,\n    print_values=False,\n    print_zeroes=False,\n    tooltip_border_radius=8,\n    tooltip_fancy_mode=True,\n    truncate_legend=-1,\n    value_formatter=lambda val: f\"{val:.1f} ft\",\n    js=[],\n)\n\n# Court markings — drawn as stroke series (no dots)\nline_style = {\"width\": 3, \"linecap\": \"round\", \"linejoin\": \"round\"}\nthin_style = {\"width\": 2.5, \"linecap\": \"round\", \"linejoin\": \"round\"}\n\nchart.add(None, baseline, stroke=True, show_dots=False, stroke_style={\"width\": 3.5, \"linecap\": \"round\"})\nchart.add(None, three_pt_arc, stroke=True, show_dots=False, stroke_style=line_style)\nchart.add(None, three_pt_left_corner, stroke=True, show_dots=False, stroke_style=line_style)\nchart.add(None, three_pt_right_corner, stroke=True, show_dots=False, stroke_style=line_style)\nchart.add(None, paint, stroke=True, show_dots=False, stroke_style=line_style)\nchart.add(None, ft_circle_top, stroke=True, show_dots=False, stroke_style=thin_style)\nchart.add(\n    None,\n    ft_circle_bottom,\n    stroke=True,\n    show_dots=False,\n    stroke_style={\"width\": 2.5, \"linecap\": \"round\", \"dasharray\": \"8,6\"},\n)\nchart.add(None, restricted_arc, stroke=True, show_dots=False, stroke_style=thin_style)\nchart.add(None, backboard, stroke=True, show_dots=False, stroke_style={\"width\": 5, \"linecap\": \"round\"})\nchart.add(None, rim, stroke=True, show_dots=False, stroke_style={\"width\": 3, \"linecap\": \"round\"})\n\n# Shot data — 4 series preserve zone-specific FG% in legend\n# Inside shots: larger markers (paint / free-throw zone)\n# Perimeter shots: smaller markers (mid-range / three-point)\nmade_inside = [\n    {\"value\": (float(x[i]), float(y[i])), \"label\": f\"{shot_type[i]} ({zones[i]}) — Made\", \"node\": {\"r\": 14}}\n    for i in range(n_shots)\n    if made[i] and zones[i] in (\"paint\", \"free_throw\")\n]\nmade_outside = [\n    {\"value\": (float(x[i]), float(y[i])), \"label\": f\"{shot_type[i]} ({zones[i]}) — Made\", \"node\": {\"r\": 9}}\n    for i in range(n_shots)\n    if made[i] and zones[i] in (\"midrange\", \"three\")\n]\nmissed_inside = [\n    {\"value\": (float(x[i]), float(y[i])), \"label\": f\"{shot_type[i]} ({zones[i]}) — Missed\", \"node\": {\"r\": 14}}\n    for i in range(n_shots)\n    if not made[i] and zones[i] in (\"paint\", \"free_throw\")\n]\nmissed_outside = [\n    {\"value\": (float(x[i]), float(y[i])), \"label\": f\"{shot_type[i]} ({zones[i]}) — Missed\", \"node\": {\"r\": 9}}\n    for i in range(n_shots)\n    if not made[i] and zones[i] in (\"midrange\", \"three\")\n]\n\nchart.add(\n    f\"Made Inside — {inside_fg:.0f}% FG ({len(made_inside)}/{n_inside})\",\n    made_inside,\n    stroke=False,\n    dots_size=15,\n    formatter=lambda val: f\"({val[0]:.1f}, {val[1]:.1f}) ft\" if isinstance(val, (list, tuple)) else str(val),\n)\nchart.add(\n    f\"Made Perimeter — {perim_fg:.0f}% FG ({len(made_outside)}/{n_perim})\",\n    made_outside,\n    stroke=False,\n    dots_size=10,\n    formatter=lambda val: f\"({val[0]:.1f}, {val[1]:.1f}) ft\" if isinstance(val, (list, tuple)) else str(val),\n)\nchart.add(\n    f\"Missed Inside ({len(missed_inside)})\",\n    missed_inside,\n    stroke=False,\n    dots_size=15,\n    formatter=lambda val: f\"({val[0]:.1f}, {val[1]:.1f}) ft\" if isinstance(val, (list, tuple)) else str(val),\n)\nchart.add(\n    f\"Missed Perimeter ({len(missed_outside)})\",\n    missed_outside,\n    stroke=False,\n    dots_size=10,\n    formatter=lambda val: f\"({val[0]:.1f}, {val[1]:.1f}) ft\" if isinstance(val, (list, tuple)) else str(val),\n)\n\n# Save — theme-suffixed for pipeline (light + dark runs)\nchart.render_to_png(f\"plot-{THEME}.png\")\nchart.render_to_file(f\"plot-{THEME}.html\")\n"}