{"spec_id":"scatter-shot-chart","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\nscatter-shot-chart: Basketball Shot Chart\nLibrary: letsplot 4.10.1 | Python 3.13.14\nQuality: 90/100 | Updated: 2026-06-21\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom lets_plot import (\n    LetsPlot,\n    aes,\n    coord_fixed,\n    element_rect,\n    element_text,\n    geom_density2d,\n    geom_path,\n    geom_point,\n    geom_rect,\n    geom_segment,\n    geom_text,\n    ggplot,\n    ggsave,\n    ggsize,\n    labs,\n    layer_tooltips,\n    scale_alpha_identity,\n    scale_color_identity,\n    scale_fill_identity,\n    scale_shape_identity,\n    scale_size_identity,\n    theme,\n    theme_void,\n    xlim,\n    ylim,\n)\n\n\nLetsPlot.setup_html()\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 assignments\nMADE_COLOR = \"#009E73\"  # Imprint position 1: green = made (good outcome)\nMISSED_COLOR = \"#AE3030\"  # Imprint semantic anchor: red = missed (bad/loss)\n\n# Court visual tokens (domain element — warm hardwood tone, theme-variant)\nCOURT_COLOR = \"#E8DCC8\" if THEME == \"light\" else \"#2A2318\"\nLINE_COLOR = \"#5A5048\" if THEME == \"light\" else \"#8A7E72\"\nANNOTATION_BG = \"rgba(232,220,200,0.85)\" if THEME == \"light\" else \"rgba(42,35,24,0.88)\"\n\n# Data\nnp.random.seed(42)\n\nn_shots = 350\nshot_x = np.concatenate(\n    [\n        np.random.normal(0, 3, 80),  # paint area\n        np.random.normal(0, 7, 60),  # mid-range center\n        np.random.normal(-12, 4, 25),  # mid-range left\n        np.random.normal(12, 4, 25),  # mid-range right\n        np.random.uniform(-22, 22, 60),  # above-break 3\n        np.random.normal(-22, 1.2, 20),  # left corner 3\n        np.random.normal(22, 1.2, 20),  # right corner 3\n        np.random.normal(0, 12, 50),  # deep 3\n        np.random.normal(0, 0.5, 10),  # free throws\n    ]\n)\nshot_y = np.concatenate(\n    [\n        np.random.uniform(0, 6, 80),  # paint area\n        np.random.uniform(6, 16, 60),  # mid-range center\n        np.random.uniform(4, 14, 25),  # mid-range left\n        np.random.uniform(4, 14, 25),  # mid-range right\n        np.random.uniform(16, 26, 60),  # above-break 3\n        np.random.uniform(0, 12, 20),  # left corner 3\n        np.random.uniform(0, 12, 20),  # right corner 3\n        np.random.uniform(24, 34, 50),  # deep 3\n        np.random.normal(15, 0.3, 10),  # free throws\n    ]\n)\n\nshot_x = np.clip(shot_x, -25, 25)\nshot_y = np.clip(shot_y, 0, 40)\n\nthree_pt_dist = np.sqrt(shot_x**2 + shot_y**2)\nis_corner_three = (np.abs(shot_x) > 21) & (shot_y < 14)\nis_three = (three_pt_dist > 23.75) | is_corner_three\nis_free_throw = (np.abs(shot_x) < 1) & (np.abs(shot_y - 15) < 1)\n\nshot_type = np.where(is_free_throw, \"free-throw\", np.where(is_three, \"3-pointer\", \"2-pointer\"))\n\nbase_pct = np.where(shot_type == \"free-throw\", 0.78, np.where(shot_type == \"2-pointer\", 0.50, 0.35))\ndist = np.sqrt(shot_x**2 + shot_y**2)\nfg_pct = np.clip(base_pct - dist * 0.004, 0.15, 0.85)\nmade = np.random.random(n_shots) < fg_pct\n\nzone = np.full(n_shots, \"Mid-Range\", dtype=object)\nzone[dist < 8] = \"Paint\"\nzone[is_three & ~is_corner_three] = \"Above Break 3\"\nzone[is_corner_three] = \"Corner 3\"\nzone[is_free_throw] = \"Free Throw\"\n\ndf = pd.DataFrame(\n    {\n        \"x\": shot_x,\n        \"y\": shot_y,\n        \"made\": made,\n        \"shot_type\": shot_type,\n        \"zone\": zone,\n        \"result\": np.where(made, \"Made\", \"Missed\"),\n        \"color\": np.where(made, MADE_COLOR, MISSED_COLOR),\n        \"fill\": np.where(made, MADE_COLOR, MISSED_COLOR),\n        \"point_size\": np.where(made, 3.0, 2.5),\n        \"point_shape\": np.where(made, 21, 4),  # circle vs X\n    }\n)\n\nzone_stats = {}\nfor z in [\"Paint\", \"Mid-Range\", \"Above Break 3\", \"Corner 3\"]:\n    mask = zone == z\n    if mask.sum() > 0:\n        zone_stats[z] = (made[mask].sum() / mask.sum() * 100, int(mask.sum()))\n\n# Court geometry (NBA half-court: 50 ft wide x 47 ft deep)\ntheta_3pt = np.linspace(-np.pi / 2 + 0.38, np.pi / 2 - 0.38, 100)\ndf_three_arc = pd.DataFrame({\"x\": 23.75 * np.cos(theta_3pt), \"y\": 23.75 * np.sin(theta_3pt)})\n\ntheta_ft_upper = np.linspace(0, np.pi, 60)\ndf_ft_arc = pd.DataFrame({\"x\": 6 * np.cos(theta_ft_upper), \"y\": 15 + 6 * np.sin(theta_ft_upper)})\ntheta_ft_lower = np.linspace(np.pi, 2 * np.pi, 60)\ndf_ft_dash = pd.DataFrame({\"x\": 6 * np.cos(theta_ft_lower), \"y\": 15 + 6 * np.sin(theta_ft_lower)})\n\ntheta_ra = np.linspace(0, np.pi, 40)\ndf_restricted = pd.DataFrame({\"x\": 4 * np.cos(theta_ra), \"y\": 4 * np.sin(theta_ra)})\n\nn_made = int(made.sum())\nn_missed = int((~made).sum())\nfg_percent = n_made / n_shots * 100\n\n# Zone annotation positions — four quadrants to avoid overlapping shot clusters\nzone_annotations = pd.DataFrame(\n    {\n        \"x\": [17.5, -20.0, 0.0, -21.0],\n        \"y\": [3.5, 15.0, 31.0, 2.5],\n        \"label\": [\n            f\"Paint\\n{zone_stats['Paint'][0]:.0f}% ({zone_stats['Paint'][1]})\",\n            f\"Mid-Range\\n{zone_stats['Mid-Range'][0]:.0f}% ({zone_stats['Mid-Range'][1]})\",\n            f\"3PT\\n{zone_stats['Above Break 3'][0]:.0f}% ({zone_stats['Above Break 3'][1]})\",\n            f\"Corner 3\\n{zone_stats['Corner 3'][0]:.0f}% ({zone_stats['Corner 3'][1]})\",\n        ],\n    }\n)\n\n# Manual legend at the bottom — wide spacing for clarity\nlegend_y = -6.5\ndf_legend = pd.DataFrame(\n    {\n        \"x\": [-16.0, 7.0],\n        \"y\": [legend_y, legend_y],\n        \"color\": [MADE_COLOR, MISSED_COLOR],\n        \"fill\": [MADE_COLOR, MISSED_COLOR],\n        \"shape\": [21, 4],\n    }\n)\ndf_legend_text = pd.DataFrame(\n    {\"x\": [-13.5, 9.5], \"y\": [legend_y, legend_y], \"label\": [f\"Made ({n_made})\", f\"Missed ({n_missed})\"]}\n)\n\ndf_made = df[df[\"made\"]].copy()\n\n# Title — 51 chars, under 67-char baseline so base_size=16 is fine\ntitle = \"scatter-shot-chart · python · letsplot · anyplot.ai\"\ntitle_len = len(title)\ntitle_size = round(16 * 67 / title_len) if title_len > 67 else 16\n\n# Plot\nplot = (\n    ggplot()\n    # Outer background (page surface)\n    + geom_rect(\n        aes(xmin=\"xmin\", ymin=\"ymin\", xmax=\"xmax\", ymax=\"ymax\"),\n        data=pd.DataFrame({\"xmin\": [-28], \"ymin\": [-10], \"xmax\": [28], \"ymax\": [49]}),\n        fill=PAGE_BG,\n        color=PAGE_BG,\n    )\n    # Court surface\n    + geom_rect(\n        aes(xmin=\"xmin\", ymin=\"ymin\", xmax=\"xmax\", ymax=\"ymax\"),\n        data=pd.DataFrame({\"xmin\": [-25], \"ymin\": [-2], \"xmax\": [25], \"ymax\": [47]}),\n        fill=COURT_COLOR,\n        color=LINE_COLOR,\n        size=1.2,\n    )\n    # Paint / key area\n    + geom_rect(\n        aes(xmin=\"xmin\", ymin=\"ymin\", xmax=\"xmax\", ymax=\"ymax\"),\n        data=pd.DataFrame({\"xmin\": [-8], \"ymin\": [0], \"xmax\": [8], \"ymax\": [19]}),\n        fill=\"rgba(0,0,0,0)\",\n        color=LINE_COLOR,\n        size=1.0,\n    )\n    # Corner three-point sidelines\n    + geom_segment(\n        aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"),\n        data=pd.DataFrame({\"x\": [-22, 22], \"y\": [0, 0], \"xend\": [-22, 22], \"yend\": [14, 14]}),\n        color=LINE_COLOR,\n        size=1.0,\n    )\n    # Three-point arc\n    + geom_path(data=df_three_arc, mapping=aes(x=\"x\", y=\"y\"), color=LINE_COLOR, size=1.0)\n    # Free-throw circle (solid top, dashed bottom)\n    + geom_path(data=df_ft_arc, mapping=aes(x=\"x\", y=\"y\"), color=LINE_COLOR, size=1.0)\n    + geom_path(data=df_ft_dash, mapping=aes(x=\"x\", y=\"y\"), color=LINE_COLOR, size=0.6, linetype=\"dashed\")\n    # Restricted area arc\n    + geom_path(data=df_restricted, mapping=aes(x=\"x\", y=\"y\"), color=LINE_COLOR, size=1.0)\n    # Backboard\n    + geom_segment(\n        aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"),\n        data=pd.DataFrame({\"x\": [-3], \"y\": [-0.5], \"xend\": [3], \"yend\": [-0.5]}),\n        color=LINE_COLOR,\n        size=2.0,\n    )\n    # Rim\n    + geom_point(aes(x=\"x\", y=\"y\"), data=pd.DataFrame({\"x\": [0], \"y\": [1.25]}), color=LINE_COLOR, size=3, shape=1)\n    # Density contours for made shots — distinctive lets-plot feature showing hot zones\n    + geom_density2d(\n        data=df_made, mapping=aes(x=\"x\", y=\"y\"), color=MADE_COLOR, alpha=0.3, size=0.7, bins=6, show_legend=False\n    )\n    # Shot data with lets-plot interactive tooltips\n    + geom_point(\n        data=df,\n        mapping=aes(x=\"x\", y=\"y\", color=\"color\", fill=\"fill\", size=\"point_size\", shape=\"point_shape\"),\n        stroke=0.3,\n        alpha=0.5,\n        tooltips=layer_tooltips()\n        .line(\"@result | @shot_type\")\n        .line(\"Zone: @zone\")\n        .format(\"x\", \".1f\")\n        .format(\"y\", \".1f\")\n        .line(\"Position: (@x, @y)\"),\n    )\n    # Zone FG% annotations — positioned in sparse court areas to minimize overlap\n    + geom_text(\n        data=zone_annotations,\n        mapping=aes(x=\"x\", y=\"y\", label=\"label\"),\n        size=10,\n        color=INK,\n        fontface=\"bold\",\n        label_padding=0.4,\n        fill=ANNOTATION_BG,\n        label_size=0,\n    )\n    # Overall FG% summary\n    + geom_text(\n        data=pd.DataFrame({\"x\": [0], \"y\": [44], \"label\": [f\"FG: {fg_percent:.1f}%  ·  {n_made}/{n_shots}\"]}),\n        mapping=aes(x=\"x\", y=\"y\", label=\"label\"),\n        size=13,\n        color=INK,\n        fontface=\"bold\",\n    )\n    # Legend markers\n    + geom_point(aes(x=\"x\", y=\"y\", color=\"color\", fill=\"fill\", shape=\"shape\"), data=df_legend, size=5, stroke=0.5)\n    # Legend labels\n    + geom_text(data=df_legend_text, mapping=aes(x=\"x\", y=\"y\", label=\"label\"), size=12, color=INK_SOFT, hjust=0)\n    + scale_color_identity()\n    + scale_fill_identity()\n    + scale_size_identity()\n    + scale_shape_identity()\n    + scale_alpha_identity()\n    + coord_fixed(ratio=1)\n    + xlim(-28, 28)\n    + ylim(-10, 49)\n    + labs(title=title)\n    + theme_void()\n    + theme(\n        plot_title=element_text(size=title_size, hjust=0.5, color=INK, face=\"bold\"),\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        plot_margin=[30, 15, 10, 15],\n    )\n    + ggsize(600, 600)\n)\n\n# Save — square canvas: ggsize(600, 600) × scale=4 → 2400×2400 px\nggsave(plot, f\"plot-{THEME}.png\", path=\".\", scale=4)\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}