{"spec_id":"scatter-shot-chart","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nscatter-shot-chart: Basketball Shot Chart\nLibrary: altair 6.2.1 | Python 3.13.14\nQuality: 90/100 | Updated: 2026-06-21\n\"\"\"\n\nimport os\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n\n# Theme tokens\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=green (#009E73), Missed=matte-red (#AE3030)\nMADE_COLOR = \"#009E73\"\nMISSED_COLOR = \"#AE3030\"\n\n# Data — realistic NBA half-court shot attempts\nnp.random.seed(42)\n\nclose_angles = np.random.uniform(0.15, np.pi - 0.15, 100)\nclose_dist = np.random.uniform(1.5, 8, 100)\nmid_angles = np.random.uniform(0.2, np.pi - 0.2, 100)\nmid_dist = np.random.uniform(8, 22, 100)\nthree_angles = np.random.uniform(0.35, np.pi - 0.35, 80)\nthree_dist = np.random.uniform(23.5, 27, 80)\nft_angles = np.random.uniform(np.pi / 2 - 0.08, np.pi / 2 + 0.08, 20)\nft_dist = np.full(20, 13.75) + np.random.normal(0, 0.3, 20)\n\nshot_x = np.concatenate(\n    [\n        close_dist * np.cos(close_angles),\n        mid_dist * np.cos(mid_angles),\n        three_dist * np.cos(three_angles),\n        ft_dist * np.cos(ft_angles),\n    ]\n)\nshot_y = np.concatenate(\n    [\n        close_dist * np.sin(close_angles),\n        mid_dist * np.sin(mid_angles),\n        three_dist * np.sin(three_angles),\n        ft_dist * np.sin(ft_angles),\n    ]\n)\n\nshot_type = [\"2-pointer\"] * 200 + [\"3-pointer\"] * 80 + [\"free-throw\"] * 20\nmake_probs = np.concatenate([np.full(100, 0.55), np.full(100, 0.40), np.full(80, 0.35), np.full(20, 0.80)])\nmade = np.random.binomial(1, make_probs).astype(bool)\n\nshots_df = pd.DataFrame(\n    {\n        \"shot_x\": np.clip(shot_x, -24.5, 24.5),\n        \"shot_y\": np.clip(shot_y, -4, 40),\n        \"result\": np.where(made, \"Made\", \"Missed\"),\n        \"shot_type\": shot_type,\n    }\n)\n\n# Court geometry (NBA half-court, basket at origin) — flat rows for Altair line mark\ntheta_ft = np.linspace(0, np.pi, 60)\ntheta_3 = np.linspace(np.arccos(22 / 23.75), np.pi - np.arccos(22 / 23.75), 100)\ntheta_ra = np.linspace(0, np.pi, 40)\ntheta_b = np.linspace(0, 2 * np.pi + 0.1, 40)\ntheta_cc = np.linspace(np.pi, 2 * np.pi, 40)\ncorner_y = np.sqrt(23.75**2 - 22**2)\n\nsegments = [\n    ([-25, -25], [-5.25, 41.75], \"sideline_l\"),\n    ([25, 25], [-5.25, 41.75], \"sideline_r\"),\n    ([-25, 25], [-5.25, -5.25], \"baseline\"),\n    ([-25, 25], [41.75, 41.75], \"halfcourt\"),\n    ([-8, -8], [-5.25, 13.75], \"paint_l\"),\n    ([8, 8], [-5.25, 13.75], \"paint_r\"),\n    ([-8, 8], [13.75, 13.75], \"ft_line\"),\n    (6 * np.cos(theta_ft), 13.75 + 6 * np.sin(theta_ft), \"ft_circle\"),\n    ([-22, -22], [-5.25, corner_y], \"corner3_l\"),\n    ([22, 22], [-5.25, corner_y], \"corner3_r\"),\n    (23.75 * np.cos(theta_3), 23.75 * np.sin(theta_3), \"three_arc\"),\n    (4 * np.cos(theta_ra), 4 * np.sin(theta_ra), \"restricted\"),\n    (0.75 * np.cos(theta_b), 0.75 * np.sin(theta_b), \"basket\"),\n    ([-3, 3], [-1.0, -1.0], \"backboard\"),\n    (6 * np.cos(theta_cc), 41.75 + 6 * np.sin(theta_cc), \"center_circle\"),\n]\n\ncourt_rows = []\nfor xs, ys, seg_name in segments:\n    for i, (xi, yi) in enumerate(zip(xs, ys, strict=True)):\n        court_rows.append({\"cx\": float(xi), \"cy\": float(yi), \"seg\": seg_name, \"ord\": i})\n\ncourt_df = pd.DataFrame(court_rows)\n\n# Zone shooting percentages\npaint_mask = (shots_df[\"shot_y\"] < 13.75) & (shots_df[\"shot_x\"].abs() < 8) & (shots_df[\"shot_type\"] != \"free-throw\")\nmid_mask = (shots_df[\"shot_type\"] == \"2-pointer\") & ~((shots_df[\"shot_y\"] < 13.75) & (shots_df[\"shot_x\"].abs() < 8))\nthree_mask = shots_df[\"shot_type\"] == \"3-pointer\"\n\npaint_pct = int(100 * shots_df.loc[paint_mask, \"result\"].eq(\"Made\").mean())\nmid_pct = int(100 * shots_df.loc[mid_mask, \"result\"].eq(\"Made\").mean())\nthree_pct = int(100 * shots_df.loc[three_mask, \"result\"].eq(\"Made\").mean())\ntotal_fg = int(100 * shots_df[\"result\"].eq(\"Made\").mean())\n\nzone_df = pd.DataFrame(\n    [\n        {\"label\": f\"Paint: {paint_pct}%\", \"zx\": 0.0, \"zy\": 6.0},\n        {\"label\": f\"Mid-Range: {mid_pct}%\", \"zx\": 0.0, \"zy\": 20.0},\n        {\"label\": f\"3PT: {three_pct}%\", \"zx\": 0.0, \"zy\": 30.0},\n    ]\n)\n\n# Shared scales — equal 52-unit domain on both axes for undistorted 1:1 court\nx_scale = alt.Scale(domain=[-26, 26], nice=False)\ny_scale = alt.Scale(domain=[-7, 45], nice=False)\n\nTITLE = \"scatter-shot-chart · python · altair · anyplot.ai\"\n\n# Court lines layer\ncourt = (\n    alt.Chart(court_df)\n    .mark_line(strokeWidth=1.5, color=INK_SOFT)\n    .encode(\n        x=alt.X(\"cx:Q\", scale=x_scale, axis=None),\n        y=alt.Y(\"cy:Q\", scale=y_scale, axis=None),\n        detail=\"seg:N\",\n        order=\"ord:Q\",\n    )\n)\n\n# Shot markers layer\nshots = (\n    alt.Chart(shots_df)\n    .mark_point(filled=True, size=55, opacity=0.6, strokeWidth=0.5, stroke=PAGE_BG)\n    .encode(\n        x=alt.X(\"shot_x:Q\", scale=x_scale, axis=None),\n        y=alt.Y(\"shot_y:Q\", scale=y_scale, axis=None),\n        color=alt.Color(\n            \"result:N\",\n            scale=alt.Scale(domain=[\"Made\", \"Missed\"], range=[MADE_COLOR, MISSED_COLOR]),\n            legend=alt.Legend(\n                title=\"Shot Result\", titleFontSize=14, labelFontSize=12, symbolSize=120, orient=\"top-right\", offset=8\n            ),\n        ),\n        shape=alt.Shape(\"result:N\", scale=alt.Scale(domain=[\"Made\", \"Missed\"], range=[\"circle\", \"cross\"]), legend=None),\n        tooltip=[\n            alt.Tooltip(\"shot_type:N\", title=\"Shot Type\"),\n            alt.Tooltip(\"result:N\", title=\"Result\"),\n            alt.Tooltip(\"shot_x:Q\", title=\"X (ft)\", format=\".1f\"),\n            alt.Tooltip(\"shot_y:Q\", title=\"Y (ft)\", format=\".1f\"),\n        ],\n    )\n)\n\n# Zone percentage labels\nzones = (\n    alt.Chart(zone_df)\n    .mark_text(fontSize=13, fontWeight=\"bold\", color=INK_MUTED, opacity=0.85)\n    .encode(x=alt.X(\"zx:Q\", scale=x_scale, axis=None), y=alt.Y(\"zy:Q\", scale=y_scale, axis=None), text=\"label:N\")\n)\n\n# Compose with theme-adaptive chrome\nchart = (\n    (court + shots + zones)\n    .properties(\n        width=500,\n        height=500,\n        background=PAGE_BG,\n        title=alt.Title(\n            TITLE,\n            fontSize=16,\n            color=INK,\n            subtitle=f\"NBA Player Shot Chart — 300 Attempts (FG {total_fg}%)\",\n            subtitleFontSize=12,\n            subtitleColor=INK_SOFT,\n            subtitlePadding=6,\n        ),\n    )\n    .interactive()\n    .configure_view(fill=PAGE_BG, strokeWidth=0)\n    .configure_title(color=INK)\n    .configure_legend(fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK)\n)\n\n# Save — pad to exact 2400×2400 (square, 1:1 court aspect ratio)\nTW, TH = 2400, 2400\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n\nchart.save(f\"plot-{THEME}.html\")\n"}