{"spec_id":"scatter-pitch-events","library":"plotnine","language":"python","code":"\"\"\" anyplot.ai\nscatter-pitch-events: Soccer Pitch Event Map\nLibrary: plotnine 0.15.7 | Python 3.13.14\nQuality: 88/100 | Updated: 2026-06-21\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom plotnine import (\n    aes,\n    annotate,\n    arrow,\n    coord_fixed,\n    element_blank,\n    element_rect,\n    element_text,\n    geom_path,\n    geom_point,\n    geom_segment,\n    ggplot,\n    guide_legend,\n    guides,\n    labs,\n    scale_alpha_manual,\n    scale_color_manual,\n    scale_shape_manual,\n    scale_size_manual,\n    scale_x_continuous,\n    scale_y_continuous,\n    theme,\n)\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\"\n\n# Imprint palette — canonical order, first series always #009E73\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\n\n# Data\nnp.random.seed(42)\n\nn_events = 120\nevent_types = np.random.choice([\"Pass\", \"Shot\", \"Tackle\", \"Interception\"], size=n_events, p=[0.50, 0.15, 0.20, 0.15])\n\nx_ranges = {\"Pass\": (10, 95), \"Shot\": (65, 100), \"Tackle\": (15, 80), \"Interception\": (20, 75)}\ny_ranges = {\"Pass\": (5, 63), \"Shot\": (15, 53), \"Tackle\": (5, 63), \"Interception\": (5, 63)}\nsuccess_p = {\"Pass\": 0.75, \"Shot\": 0.30, \"Tackle\": 0.65, \"Interception\": 0.70}\n\nx_positions = np.array([np.random.uniform(*x_ranges[e]) for e in event_types])\ny_positions = np.array([np.random.uniform(*y_ranges[e]) for e in event_types])\noutcomes = [np.random.choice([\"Successful\", \"Unsuccessful\"], p=[success_p[e], 1 - success_p[e]]) for e in event_types]\n\n# Arrow endpoints: passes forward-biased, shots spread across goal area to reduce congestion\nx_end = x_positions.copy()\ny_end = y_positions.copy()\nfor i, evt in enumerate(event_types):\n    if evt == \"Pass\":\n        dx = np.random.uniform(5, 20) * np.random.choice([-1, 1], p=[0.15, 0.85])\n        dy = np.random.uniform(-12, 12)\n        x_end[i] = np.clip(x_positions[i] + dx, 0, 105)\n        y_end[i] = np.clip(y_positions[i] + dy, 0, 68)\n    elif evt == \"Shot\":\n        # Spread endpoints across goal area rather than converging to a single point\n        x_end[i] = np.random.uniform(98, 105)\n        y_end[i] = np.random.uniform(27, 41)\n\ndf = pd.DataFrame(\n    {\n        \"x\": x_positions,\n        \"y\": y_positions,\n        \"x_end\": x_end,\n        \"y_end\": y_end,\n        \"event_type\": pd.Categorical(event_types, categories=[\"Pass\", \"Shot\", \"Tackle\", \"Interception\"]),\n        \"outcome\": pd.Categorical(outcomes, categories=[\"Successful\", \"Unsuccessful\"]),\n    }\n)\n\ndf_pass_arrows = df[df[\"event_type\"] == \"Pass\"].copy()\ndf_shot_arrows = df[df[\"event_type\"] == \"Shot\"].copy()\n# Reorder so shots render on top (last drawn = topmost layer)\ndf_ordered = pd.concat([df[df[\"event_type\"] != \"Shot\"], df[df[\"event_type\"] == \"Shot\"]], ignore_index=True)\n\n# Pitch styling — deep green with semi-transparent white lines\npitch_color = \"#1a6b30\"\nline_color = \"#ffffffcc\"\nlw = 0.7\n\n# Curves: center circle, penalty arcs, corner arcs\ntheta = np.linspace(0, 2 * np.pi, 100)\ncenter_circle = pd.DataFrame({\"cx\": 52.5 + 9.15 * np.cos(theta), \"cy\": 34 + 9.15 * np.sin(theta), \"grp\": 1})\n\ntheta_left = np.linspace(-0.65, 0.65, 50)\nleft_arc = pd.DataFrame({\"cx\": 11 + 9.15 * np.cos(theta_left), \"cy\": 34 + 9.15 * np.sin(theta_left), \"grp\": 2})\n\ntheta_right = np.linspace(np.pi - 0.65, np.pi + 0.65, 50)\nright_arc = pd.DataFrame({\"cx\": 94 + 9.15 * np.cos(theta_right), \"cy\": 34 + 9.15 * np.sin(theta_right), \"grp\": 3})\n\nca_r = 1.0\ncorner_arcs = pd.concat(\n    [\n        pd.DataFrame(\n            {\n                \"cx\": ca_r * np.cos(np.linspace(0, np.pi / 2, 20)),\n                \"cy\": ca_r * np.sin(np.linspace(0, np.pi / 2, 20)),\n                \"grp\": 4,\n            }\n        ),\n        pd.DataFrame(\n            {\n                \"cx\": ca_r * np.cos(np.linspace(np.pi / 2, np.pi, 20)),\n                \"cy\": 68 + ca_r * np.sin(np.linspace(np.pi / 2, np.pi, 20)),\n                \"grp\": 5,\n            }\n        ),\n        pd.DataFrame(\n            {\n                \"cx\": 105 + ca_r * np.cos(np.linspace(-np.pi / 2, 0, 20)),\n                \"cy\": ca_r * np.sin(np.linspace(-np.pi / 2, 0, 20)),\n                \"grp\": 6,\n            }\n        ),\n        pd.DataFrame(\n            {\n                \"cx\": 105 + ca_r * np.cos(np.linspace(np.pi, 3 * np.pi / 2, 20)),\n                \"cy\": 68 + ca_r * np.sin(np.linspace(np.pi, 3 * np.pi / 2, 20)),\n                \"grp\": 7,\n            }\n        ),\n    ],\n    ignore_index=True,\n)\n\nall_curves = pd.concat([center_circle, left_arc, right_arc, corner_arcs], ignore_index=True)\n\n# Imprint palette mapped to event types (canonical positions 1–4)\nevent_colors = {\n    \"Pass\": IMPRINT_PALETTE[0],  # #009E73 brand green\n    \"Shot\": IMPRINT_PALETTE[1],  # #C475FD lavender\n    \"Tackle\": IMPRINT_PALETTE[2],  # #4467A3 blue\n    \"Interception\": IMPRINT_PALETTE[3],  # #BD8233 ochre\n}\nevent_shapes = {\"Pass\": \"o\", \"Shot\": \"*\", \"Tackle\": \"^\", \"Interception\": \"D\"}\n\n# Title — compute fontsize proportional to length\ntitle = \"scatter-pitch-events · python · plotnine · anyplot.ai\"\nn_chars = len(title)\nratio = 67 / n_chars if n_chars > 67 else 1.0\ntitle_fontsize = max(8, round(12 * ratio))\n\n# Plot\nplot = (\n    ggplot(df_ordered, aes(x=\"x\", y=\"y\", color=\"event_type\", shape=\"event_type\", alpha=\"outcome\", size=\"event_type\"))\n    # Pitch background — extended to fill canvas edges\n    + annotate(\"rect\", xmin=-6, xmax=111, ymin=-6, ymax=74, fill=pitch_color, color=pitch_color)\n    # Subtle grass stripe effect\n    + annotate(\"rect\", xmin=0, xmax=105, ymin=0, ymax=68, fill=\"#1e7535\", alpha=0.3, color=\"none\")\n    # Pitch outline\n    + annotate(\"rect\", xmin=0, xmax=105, ymin=0, ymax=68, fill=\"none\", color=line_color, size=lw)\n    # Halfway line\n    + annotate(\"segment\", x=52.5, xend=52.5, y=0, yend=68, color=line_color, size=lw)\n    # Center spot\n    + annotate(\"point\", x=52.5, y=34, color=line_color, size=1.8, shape=\"o\", fill=line_color)\n    # Left penalty area\n    + annotate(\"rect\", xmin=0, xmax=16.5, ymin=13.84, ymax=54.16, fill=\"none\", color=line_color, size=lw)\n    # Right penalty area\n    + annotate(\"rect\", xmin=88.5, xmax=105, ymin=13.84, ymax=54.16, fill=\"none\", color=line_color, size=lw)\n    # Left goal area\n    + annotate(\"rect\", xmin=0, xmax=5.5, ymin=24.84, ymax=43.16, fill=\"none\", color=line_color, size=lw)\n    # Right goal area\n    + annotate(\"rect\", xmin=99.5, xmax=105, ymin=24.84, ymax=43.16, fill=\"none\", color=line_color, size=lw)\n    # Penalty spots\n    + annotate(\"point\", x=11, y=34, color=line_color, size=1.2, shape=\"o\", fill=line_color)\n    + annotate(\"point\", x=94, y=34, color=line_color, size=1.2, shape=\"o\", fill=line_color)\n    # Left goal posts\n    + annotate(\"segment\", x=-2, xend=-2, y=30.34, yend=37.66, color=\"#ffffff\", size=1.5)\n    + annotate(\"segment\", x=-2, xend=0, y=30.34, yend=30.34, color=\"#ffffff\", size=0.5)\n    + annotate(\"segment\", x=-2, xend=0, y=37.66, yend=37.66, color=\"#ffffff\", size=0.5)\n    # Right goal posts\n    + annotate(\"segment\", x=107, xend=107, y=30.34, yend=37.66, color=\"#ffffff\", size=1.5)\n    + annotate(\"segment\", x=105, xend=107, y=30.34, yend=30.34, color=\"#ffffff\", size=0.5)\n    + annotate(\"segment\", x=105, xend=107, y=37.66, yend=37.66, color=\"#ffffff\", size=0.5)\n    # Curves: center circle, penalty arcs, corner arcs\n    + geom_path(data=all_curves, mapping=aes(x=\"cx\", y=\"cy\", group=\"grp\"), color=line_color, size=lw, inherit_aes=False)\n    # Pass arrows — thin and subtle to avoid midfield clutter\n    + geom_segment(\n        data=df_pass_arrows,\n        mapping=aes(x=\"x\", y=\"y\", xend=\"x_end\", yend=\"y_end\", alpha=\"outcome\"),\n        color=event_colors[\"Pass\"],\n        size=0.4,\n        arrow=arrow(length=0.10, type=\"open\"),\n        inherit_aes=False,\n    )\n    # Shot arrows — bolder to emphasize attacking intent\n    + geom_segment(\n        data=df_shot_arrows,\n        mapping=aes(x=\"x\", y=\"y\", xend=\"x_end\", yend=\"y_end\", alpha=\"outcome\"),\n        color=event_colors[\"Shot\"],\n        size=0.9,\n        arrow=arrow(length=0.18, type=\"open\"),\n        inherit_aes=False,\n    )\n    # All event markers — size driven by scale_size_manual (shots=8, others=4.5)\n    + geom_point(stroke=0.4)\n    # Scales\n    + scale_color_manual(values=event_colors, name=\"Event Type\")\n    + scale_shape_manual(values=event_shapes, name=\"Event Type\")\n    + scale_alpha_manual(values={\"Successful\": 0.92, \"Unsuccessful\": 0.40}, name=\"Outcome\")\n    + scale_size_manual(values={\"Pass\": 4.5, \"Shot\": 8, \"Tackle\": 4.5, \"Interception\": 4.5}, name=\"Event Type\")\n    + scale_x_continuous(limits=(-10, 115), breaks=[])\n    + scale_y_continuous(limits=(-8, 76), breaks=[])\n    + coord_fixed(ratio=1)\n    + labs(title=title, subtitle=\"120 match events: passes, shots, tackles & interceptions\")\n    + guides(\n        color=guide_legend(override_aes={\"size\": 5}),\n        alpha=guide_legend(override_aes={\"size\": 5, \"alpha\": [0.92, 0.40]}),\n    )\n    + theme(\n        figure_size=(8, 4.5),\n        plot_title=element_text(size=title_fontsize, weight=\"bold\", color=INK, margin={\"b\": 4}),\n        plot_subtitle=element_text(size=9, color=INK_SOFT, style=\"italic\", margin={\"b\": 8}),\n        panel_background=element_rect(fill=PAGE_BG, color=\"none\"),\n        plot_background=element_rect(fill=PAGE_BG, color=\"none\"),\n        panel_border=element_blank(),\n        panel_grid_major=element_blank(),\n        panel_grid_minor=element_blank(),\n        axis_title=element_blank(),\n        axis_text=element_blank(),\n        axis_ticks=element_blank(),\n        legend_title=element_text(size=9, weight=\"bold\", color=INK),\n        legend_text=element_text(size=8, color=INK_SOFT),\n        legend_position=\"right\",\n        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\n        legend_key=element_rect(fill=ELEVATED_BG, color=\"none\"),\n        plot_margin=0.04,\n    )\n)\n\n# Save\nplot.save(f\"plot-{THEME}.png\", dpi=400, width=8, height=4.5, units=\"in\", verbose=False)\n"}