{"spec_id":"scatter-pitch-events","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\nscatter-pitch-events: Soccer Pitch Event Map\nLibrary: letsplot 4.10.1 | Python 3.13.14\nQuality: 87/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    arrow,\n    coord_fixed,\n    element_rect,\n    element_text,\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 (Imprint palette, theme-adaptive chrome)\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\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# Pitch color is data context — stays constant across themes\nPITCH_GREEN = \"#1A5C2A\"  # darker forest green keeps #009E73 markers visually distinct\nPITCH_LINE = \"#FFFFFF\"\n\n# Imprint palette (canonical order) mapped to 4 event types\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\npass_color = IMPRINT_PALETTE[0]  # brand green\nshot_color = IMPRINT_PALETTE[1]  # lavender\ntackle_color = IMPRINT_PALETTE[2]  # blue\nintercept_color = IMPRINT_PALETTE[3]  # ochre\n\ncolor_map = {\"Pass\": pass_color, \"Shot\": shot_color, \"Tackle\": tackle_color, \"Interception\": intercept_color}\nshape_map = {\"Pass\": 21, \"Shot\": 23, \"Tackle\": 24, \"Interception\": 22}\n\n# Data\nnp.random.seed(42)\nn_events = 100\nevent_types = np.random.choice([\"Pass\", \"Shot\", \"Tackle\", \"Interception\"], size=n_events, p=[0.45, 0.15, 0.22, 0.18])\nsuccess_rates = {\"Pass\": 0.78, \"Shot\": 0.30, \"Tackle\": 0.60, \"Interception\": 0.70}\noutcomes = [\n    np.random.choice([\"Successful\", \"Unsuccessful\"], p=[success_rates[et], 1 - success_rates[et]]) for et in event_types\n]\n\nx_pos, y_pos, x_end, y_end = [], [], [], []\nfor et in event_types:\n    if et == \"Pass\":\n        x = np.random.uniform(10, 95)\n        y = np.random.uniform(5, 63)\n        dx = np.random.uniform(5, 25) * np.random.choice([-1, 1], p=[0.2, 0.8])\n        dy = np.random.uniform(-15, 15)\n        xe, ye = np.clip(x + dx, 0, 105), np.clip(y + dy, 0, 68)\n    elif et == \"Shot\":\n        x = np.random.uniform(55, 100)\n        y = np.random.uniform(10, 58)\n        xe, ye = 105.0, np.random.uniform(28, 40)\n    else:\n        x = np.random.uniform(15, 85)\n        y = np.random.uniform(5, 63)\n        xe, ye = x, y\n    x_pos.append(x)\n    y_pos.append(y)\n    x_end.append(xe)\n    y_end.append(ye)\n\ndf = pd.DataFrame(\n    {\"x\": x_pos, \"y\": y_pos, \"x_end\": x_end, \"y_end\": y_end, \"event_type\": event_types, \"outcome\": outcomes}\n)\ndf[\"color\"] = df[\"event_type\"].map(color_map)\ndf[\"shape\"] = df[\"event_type\"].map(shape_map)\n# Clearer alpha encoding: successful=1.0 opaque, unsuccessful=0.42 semi-transparent\ndf[\"alpha\"] = np.where(df[\"outcome\"] == \"Successful\", 1.0, 0.42)\ndf[\"fill\"] = np.where(df[\"outcome\"] == \"Successful\", df[\"color\"], \"#FFFFFF\")\ndf[\"marker_size\"] = np.where(df[\"event_type\"] == \"Shot\", 4.5, 3.0)\n\ndf_arrows = df[df[\"event_type\"].isin([\"Pass\", \"Shot\"])].copy()\n\n# Pitch geometry\ntheta = np.linspace(0, 2 * np.pi, 80)\ndf_center_circle = pd.DataFrame({\"x\": 52.5 + 9.15 * np.cos(theta), \"y\": 34 + 9.15 * np.sin(theta)})\n\ntheta_l = np.linspace(-np.pi / 2, np.pi / 2, 40)\narc_lx = 11 + 9.15 * np.cos(theta_l)\narc_ly = 34 + 9.15 * np.sin(theta_l)\ndf_left_arc = pd.DataFrame({\"x\": arc_lx[arc_lx >= 16.5], \"y\": arc_ly[arc_lx >= 16.5]})\n\ntheta_r = np.linspace(np.pi / 2, 3 * np.pi / 2, 40)\narc_rx = 94 + 9.15 * np.cos(theta_r)\narc_ry = 34 + 9.15 * np.sin(theta_r)\ndf_right_arc = pd.DataFrame({\"x\": arc_rx[arc_rx <= 88.5], \"y\": arc_ry[arc_rx <= 88.5]})\n\ncorner_arc_dfs = []\nfor cx, cy, t0, t1 in [\n    (0, 0, 0, np.pi / 2),\n    (0, 68, -np.pi / 2, 0),\n    (105, 0, np.pi / 2, np.pi),\n    (105, 68, np.pi, 3 * np.pi / 2),\n]:\n    t = np.linspace(t0, t1, 20)\n    corner_arc_dfs.append(pd.DataFrame({\"x\": cx + np.cos(t), \"y\": cy + np.sin(t)}))\n\ndf_rects = pd.DataFrame(\n    {\n        \"xmin\": [0, 0, 0, 88.5, 99.5],\n        \"ymin\": [0, 13.84, 24.84, 13.84, 24.84],\n        \"xmax\": [105, 16.5, 5.5, 105, 105],\n        \"ymax\": [68, 54.16, 43.16, 54.16, 43.16],\n    }\n)\n\n# Zone highlights with improved visibility\ndf_attack_zone = pd.DataFrame({\"xmin\": [70], \"ymin\": [0], \"xmax\": [105], \"ymax\": [68]})\ndf_defend_zone = pd.DataFrame({\"xmin\": [0], \"ymin\": [0], \"xmax\": [35], \"ymax\": [68]})\n\n# Custom legend below the pitch\nlegend_x = [12, 37, 62, 87]\ndf_legend_markers = pd.DataFrame(\n    {\n        \"x\": legend_x,\n        \"y\": [-8.0] * 4,\n        \"color\": [pass_color, shot_color, tackle_color, intercept_color],\n        \"shape\": [21, 23, 24, 22],\n        \"fill\": [pass_color, shot_color, tackle_color, intercept_color],\n    }\n)\ndf_legend_labels = pd.DataFrame({\"x\": legend_x, \"y\": [-12.5] * 4, \"label\": [\"Pass\", \"Shot\", \"Tackle\", \"Interception\"]})\ndf_outcome_text = pd.DataFrame(\n    {\"x\": [28, 78], \"y\": [-17.0, -17.0], \"label\": [\"● Colored fill = Successful\", \"○ White fill = Unsuccessful\"]}\n)\n\n# Plot\nplot = (\n    ggplot()\n    # Pitch surface\n    + geom_rect(\n        aes(xmin=\"xmin\", ymin=\"ymin\", xmax=\"xmax\", ymax=\"ymax\"),\n        data=pd.DataFrame({\"xmin\": [-4], \"ymin\": [-4], \"xmax\": [109], \"ymax\": [72]}),\n        fill=PITCH_GREEN,\n        color=PITCH_GREEN,\n    )\n    # Zone highlights — alpha 0.15/0.12 for perceptibility (was 0.08/0.06)\n    + geom_rect(\n        aes(xmin=\"xmin\", ymin=\"ymin\", xmax=\"xmax\", ymax=\"ymax\"),\n        data=df_attack_zone,\n        fill=\"#FFFFFF\",\n        color=\"rgba(0,0,0,0)\",\n        alpha=0.15,\n    )\n    + geom_rect(\n        aes(xmin=\"xmin\", ymin=\"ymin\", xmax=\"xmax\", ymax=\"ymax\"),\n        data=df_defend_zone,\n        fill=\"#000000\",\n        color=\"rgba(0,0,0,0)\",\n        alpha=0.12,\n    )\n    # Pitch markings\n    + geom_rect(\n        aes(xmin=\"xmin\", ymin=\"ymin\", xmax=\"xmax\", ymax=\"ymax\"),\n        data=df_rects,\n        fill=\"rgba(0,0,0,0)\",\n        color=PITCH_LINE,\n        size=0.8,\n    )\n    + geom_segment(\n        aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"),\n        data=pd.DataFrame({\"x\": [52.5], \"y\": [0], \"xend\": [52.5], \"yend\": [68]}),\n        color=PITCH_LINE,\n        size=0.8,\n    )\n    + geom_segment(\n        aes(x=\"x\", y=\"y\", xend=\"xend\", yend=\"yend\"),\n        data=pd.DataFrame({\"x\": [0, 105], \"y\": [30.34, 30.34], \"xend\": [0, 105], \"yend\": [37.66, 37.66]}),\n        color=\"#DDDDDD\",\n        size=2.0,\n    )\n    + geom_path(data=df_center_circle, mapping=aes(x=\"x\", y=\"y\"), color=PITCH_LINE, size=0.8)\n    + geom_path(data=df_left_arc, mapping=aes(x=\"x\", y=\"y\"), color=PITCH_LINE, size=0.8)\n    + geom_path(data=df_right_arc, mapping=aes(x=\"x\", y=\"y\"), color=PITCH_LINE, size=0.8)\n    + geom_path(data=corner_arc_dfs[0], mapping=aes(x=\"x\", y=\"y\"), color=PITCH_LINE, size=0.8)\n    + geom_path(data=corner_arc_dfs[1], mapping=aes(x=\"x\", y=\"y\"), color=PITCH_LINE, size=0.8)\n    + geom_path(data=corner_arc_dfs[2], mapping=aes(x=\"x\", y=\"y\"), color=PITCH_LINE, size=0.8)\n    + geom_path(data=corner_arc_dfs[3], mapping=aes(x=\"x\", y=\"y\"), color=PITCH_LINE, size=0.8)\n    + geom_point(\n        aes(x=\"x\", y=\"y\"), data=pd.DataFrame({\"x\": [52.5, 11, 94], \"y\": [34, 34, 34]}), color=PITCH_LINE, size=1.5\n    )\n    # Directional arrows for passes and shots\n    + geom_segment(\n        data=df_arrows,\n        mapping=aes(x=\"x\", y=\"y\", xend=\"x_end\", yend=\"y_end\", color=\"color\", alpha=\"alpha\"),\n        size=0.6,\n        arrow=arrow(length=6, type=\"open\"),\n    )\n    # Event markers (shots enlarged for tactical emphasis)\n    + geom_point(\n        data=df,\n        mapping=aes(x=\"x\", y=\"y\", color=\"color\", fill=\"fill\", shape=\"shape\", alpha=\"alpha\", size=\"marker_size\"),\n        stroke=1.2,\n        tooltips=layer_tooltips().line(\"@event_type\").line(\"Outcome: @outcome\").line(\"x=@x, y=@y\"),\n    )\n    # Zone annotation labels\n    + geom_text(\n        data=pd.DataFrame({\"x\": [87.5], \"y\": [65.5], \"label\": [\"Attacking Third\"]}),\n        mapping=aes(x=\"x\", y=\"y\", label=\"label\"),\n        size=5,\n        color=\"#FFFFFF\",\n        alpha=0.7,\n        fontface=\"italic\",\n    )\n    + geom_text(\n        data=pd.DataFrame({\"x\": [17.5], \"y\": [65.5], \"label\": [\"Defensive Third\"]}),\n        mapping=aes(x=\"x\", y=\"y\", label=\"label\"),\n        size=5,\n        color=\"#FFFFFF\",\n        alpha=0.65,\n        fontface=\"italic\",\n    )\n    # Custom legend markers and labels\n    + geom_point(\n        data=df_legend_markers,\n        mapping=aes(x=\"x\", y=\"y\", color=\"color\", fill=\"fill\", shape=\"shape\"),\n        size=3.5,\n        stroke=1.0,\n    )\n    + geom_text(\n        data=df_legend_labels, mapping=aes(x=\"x\", y=\"y\", label=\"label\"), size=5, color=INK_SOFT, fontface=\"bold\"\n    )\n    + geom_text(data=df_outcome_text, mapping=aes(x=\"x\", y=\"y\", label=\"label\"), size=5, color=INK_MUTED)\n    + scale_color_identity()\n    + scale_fill_identity()\n    + scale_shape_identity()\n    + scale_alpha_identity()\n    + scale_size_identity()\n    + coord_fixed(ratio=1)\n    + xlim(-5, 112)\n    + ylim(-20, 76)\n    + labs(\n        title=\"scatter-pitch-events · python · letsplot · anyplot.ai\",\n        subtitle=\"100 match events — passes, shots, tackles & interceptions with outcome encoding\",\n    )\n    + theme_void()\n    + theme(\n        plot_title=element_text(size=16, hjust=0.5, color=INK, face=\"bold\"),\n        plot_subtitle=element_text(size=10, hjust=0.5, color=INK_SOFT),\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        plot_margin=[25, 15, 10, 15],\n    )\n    + ggsize(800, 450)\n)\n\n# Save PNG and HTML for this theme\nggsave(plot, f\"plot-{THEME}.png\", path=\".\", scale=4)\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}