{"spec_id":"scatter-pitch-events","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nscatter-pitch-events: Soccer Pitch Event Map\nLibrary: bokeh 3.9.1 | Python 3.13.14\nQuality: 88/100 | Updated: 2026-06-21\n\"\"\"\n\nimport os\nimport sys\n\n\n# Prevent self-import: this file is named bokeh.py, which shadows the installed\n# bokeh package when its directory sits at the front of sys.path.\n_this_dir = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.abspath(p or \".\") != _this_dir]\n\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\nfrom bokeh.io import output_file, save\nfrom bokeh.models import Arrow, ColumnDataSource, Label, NormalHead, Range1d\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\n# Theme tokens (see prompts/default-style-guide.md \"Theme-adaptive Chrome\")\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 — semantic assignments for soccer event types\nPASS_COLOR = \"#009E73\"  # position 1 (brand green) — passes, first series\nSHOT_COLOR = \"#AE3030\"  # position 5 (matte red) — shots, danger/goal semantic\nTACKLE_COLOR = \"#4467A3\"  # position 3 (blue) — tackles\nINTERCEPT_COLOR = \"#BD8233\"  # position 4 (ochre) — interceptions\n\nevent_colors = {\"pass\": PASS_COLOR, \"shot\": SHOT_COLOR, \"tackle\": TACKLE_COLOR, \"interception\": INTERCEPT_COLOR}\nevent_markers = {\"pass\": \"circle\", \"shot\": \"star\", \"tackle\": \"triangle\", \"interception\": \"diamond\"}\nevent_sizes = {\"pass\": 18, \"shot\": 28, \"tackle\": 20, \"interception\": 22}\n\n# Data\nnp.random.seed(42)\nn_events = 120\n\nevent_types = np.random.choice([\"pass\", \"shot\", \"tackle\", \"interception\"], size=n_events, p=[0.45, 0.15, 0.22, 0.18])\n\nx_start = np.zeros(n_events)\ny_start = np.zeros(n_events)\nx_end = np.zeros(n_events)\ny_end = np.zeros(n_events)\noutcomes = []\n\nfor i, etype in enumerate(event_types):\n    if etype == \"pass\":\n        x_start[i] = np.random.uniform(10, 90)\n        y_start[i] = np.random.uniform(5, 63)\n        angle = np.random.uniform(-np.pi / 2, np.pi / 2)\n        dist = np.random.uniform(5, 40)\n        x_end[i] = np.clip(x_start[i] + dist * np.cos(angle), 0, 105)\n        y_end[i] = np.clip(y_start[i] + dist * np.sin(angle), 0, 68)\n        outcomes.append(np.random.choice([\"successful\", \"unsuccessful\"], p=[0.78, 0.22]))\n    elif etype == \"shot\":\n        x_start[i] = np.random.uniform(70, 100)\n        y_start[i] = np.random.uniform(15, 53)\n        x_end[i] = 105\n        y_end[i] = np.random.uniform(28, 40)\n        outcomes.append(np.random.choice([\"successful\", \"unsuccessful\"], p=[0.30, 0.70]))\n    elif etype == \"tackle\":\n        x_start[i] = np.random.uniform(15, 75)\n        y_start[i] = np.random.uniform(5, 63)\n        x_end[i] = x_start[i]\n        y_end[i] = y_start[i]\n        outcomes.append(np.random.choice([\"successful\", \"unsuccessful\"], p=[0.65, 0.35]))\n    else:\n        x_start[i] = np.random.uniform(20, 80)\n        y_start[i] = np.random.uniform(5, 63)\n        x_end[i] = x_start[i]\n        y_end[i] = y_start[i]\n        outcomes.append(np.random.choice([\"successful\", \"unsuccessful\"], p=[0.72, 0.28]))\n\noutcomes = np.array(outcomes)\ndf = pd.DataFrame(\n    {\"x\": x_start, \"y\": y_start, \"x_end\": x_end, \"y_end\": y_end, \"event_type\": event_types, \"outcome\": outcomes}\n)\n\n# Pitch styling (theme-adaptive)\nPITCH_FILL = \"#3d8b45\" if THEME == \"light\" else \"#2a6030\"\nPITCH_LINE = \"#2E7D32\" if THEME == \"light\" else \"#4CAF50\"\nGOAL_COLOR = \"#888888\" if THEME == \"light\" else \"#AAAAAA\"\n\n# Plot — 3200x1800 landscape, toolbar disabled to avoid height drift.\n# match_aspect=True would resize the canvas to enforce equal axis scales, breaking\n# the 3200x1800 contract. Instead, data ranges are chosen so that\n# x_range/y_range ≈ (3200-230)/(1800-270) = 2970/1530 ≈ 1.941, giving equal\n# pixels-per-meter on both axes without distorting the pitch geometry.\np = figure(\n    width=3200,\n    height=1800,\n    title=\"scatter-pitch-events · python · bokeh · anyplot.ai\",\n    x_range=Range1d(-32.5, 137.5),\n    y_range=Range1d(-10, 78),\n    toolbar_location=None,\n    min_border_bottom=220,\n    min_border_left=180,\n    min_border_top=110,\n    min_border_right=50,\n)\n\n# Pitch background\np.rect(x=52.5, y=34, width=105, height=68, fill_color=PITCH_FILL, fill_alpha=0.12, line_color=None)\n\n# Mow-pattern stripes\nfor stripe_x in range(0, 105, 10):\n    stripe_alpha = 0.08 if (stripe_x // 10) % 2 == 0 else 0.0\n    p.rect(x=stripe_x + 5, y=34, width=10, height=68, fill_color=PITCH_LINE, fill_alpha=stripe_alpha, line_color=None)\n\n# Danger zone gradient (attacking third)\np.rect(x=96, y=34, width=18, height=68, fill_color=SHOT_COLOR, fill_alpha=0.08, line_color=None)\np.rect(x=100, y=34, width=10, height=68, fill_color=SHOT_COLOR, fill_alpha=0.05, line_color=None)\n\n# Pitch outline\np.line([0, 105, 105, 0, 0], [0, 0, 68, 68, 0], line_color=PITCH_LINE, line_width=4)\n\n# Halfway line\np.line([52.5, 52.5], [0, 68], line_color=PITCH_LINE, line_width=3)\n\n# Center circle and spot\ntheta = np.linspace(0, 2 * np.pi, 100)\np.line(52.5 + 9.15 * np.cos(theta), 34 + 9.15 * np.sin(theta), line_color=PITCH_LINE, line_width=3)\np.scatter([52.5], [34], size=10, color=PITCH_LINE)\n\n# Penalty areas\np.line([0, 16.5, 16.5, 0], [13.85, 13.85, 54.15, 54.15], line_color=PITCH_LINE, line_width=3)\np.line([105, 88.5, 88.5, 105], [13.85, 13.85, 54.15, 54.15], line_color=PITCH_LINE, line_width=3)\n\n# Goal areas\np.line([0, 5.5, 5.5, 0], [24.85, 24.85, 43.15, 43.15], line_color=PITCH_LINE, line_width=3)\np.line([105, 99.5, 99.5, 105], [24.85, 24.85, 43.15, 43.15], line_color=PITCH_LINE, line_width=3)\n\n# Penalty spots and arcs\np.scatter([11, 94], [34, 34], size=8, color=PITCH_LINE)\narc_theta = np.linspace(-0.93, 0.93, 50)\np.line(11 + 9.15 * np.cos(arc_theta), 34 + 9.15 * np.sin(arc_theta), line_color=PITCH_LINE, line_width=3)\np.line(94 - 9.15 * np.cos(arc_theta), 34 + 9.15 * np.sin(arc_theta), line_color=PITCH_LINE, line_width=3)\n\n# Corner arcs\nfor cx, cy, a0, a1 in [\n    (0, 0, 0, np.pi / 2),\n    (105, 0, np.pi / 2, np.pi),\n    (105, 68, np.pi, 3 * np.pi / 2),\n    (0, 68, 3 * np.pi / 2, 2 * np.pi),\n]:\n    ca = np.linspace(a0, a1, 25)\n    p.line(cx + 1 * np.cos(ca), cy + 1 * np.sin(ca), line_color=PITCH_LINE, line_width=3)\n\n# Goal posts\np.line([-1.5, 0], [30.34, 30.34], line_color=GOAL_COLOR, line_width=6)\np.line([-1.5, 0], [37.66, 37.66], line_color=GOAL_COLOR, line_width=6)\np.line([-1.5, -1.5], [30.34, 37.66], line_color=GOAL_COLOR, line_width=6)\np.line([105, 106.5], [30.34, 30.34], line_color=GOAL_COLOR, line_width=6)\np.line([105, 106.5], [37.66, 37.66], line_color=GOAL_COLOR, line_width=6)\np.line([106.5, 106.5], [30.34, 37.66], line_color=GOAL_COLOR, line_width=6)\n\n# Directional arrows for passes and shots\narrow_data = df[df[\"event_type\"].isin([\"pass\", \"shot\"])]\nfor _, row in arrow_data.iterrows():\n    color = event_colors[row[\"event_type\"]]\n    is_pass = row[\"event_type\"] == \"pass\"\n    alpha = (\n        (0.35 if row[\"outcome\"] == \"successful\" else 0.20)\n        if is_pass\n        else (0.55 if row[\"outcome\"] == \"successful\" else 0.25)\n    )\n    lw = 2.5 if row[\"event_type\"] == \"shot\" else 1.3\n    head_size = 14 if row[\"event_type\"] == \"shot\" else 10\n    p.add_layout(\n        Arrow(\n            end=NormalHead(size=head_size, fill_color=color, fill_alpha=alpha, line_color=color, line_alpha=alpha),\n            x_start=row[\"x\"],\n            y_start=row[\"y\"],\n            x_end=row[\"x_end\"],\n            y_end=row[\"y_end\"],\n            line_color=color,\n            line_alpha=alpha,\n            line_width=lw,\n        )\n    )\n\n# Event markers — shots drawn last for visual emphasis\nfor etype in [\"pass\", \"tackle\", \"interception\", \"shot\"]:\n    for outcome in [\"successful\", \"unsuccessful\"]:\n        mask = (df[\"event_type\"] == etype) & (df[\"outcome\"] == outcome)\n        subset = df[mask]\n        if len(subset) == 0:\n            continue\n        color = event_colors[etype]\n        fill_alpha = 0.90 if outcome == \"successful\" else 0.28\n        border_color = \"white\" if etype == \"shot\" else color\n        line_w = 2 if etype == \"shot\" else 2.5\n        source = ColumnDataSource(data={\"x\": subset[\"x\"].values, \"y\": subset[\"y\"].values})\n        p.scatter(\n            x=\"x\",\n            y=\"y\",\n            source=source,\n            marker=event_markers[etype],\n            size=event_sizes[etype],\n            fill_color=color,\n            fill_alpha=fill_alpha,\n            line_color=border_color,\n            line_width=line_w,\n            line_alpha=0.95,\n            legend_label=f\"{etype.capitalize()} ({'success' if outcome == 'successful' else 'miss'})\",\n        )\n\n# Storytelling annotation — danger zone shot summary\nshot_data = df[df[\"event_type\"] == \"shot\"]\nn_shots = len(shot_data)\nn_on_target = len(shot_data[shot_data[\"outcome\"] == \"successful\"])\np.add_layout(\n    Label(\n        x=96,\n        y=66,\n        text=f\"{n_shots} shots · {n_on_target} on target\",\n        text_font_size=\"26pt\",\n        text_color=SHOT_COLOR,\n        text_font_style=\"bold\",\n        text_alpha=0.9,\n    )\n)\n\n# Legend — single row to reduce density\np.legend.location = \"bottom_center\"\np.legend.orientation = \"horizontal\"\np.legend.label_text_font_size = \"34pt\"\np.legend.label_text_color = INK_SOFT\np.legend.glyph_width = 30\np.legend.glyph_height = 30\np.legend.spacing = 20\np.legend.padding = 12\np.legend.background_fill_alpha = 0.92\np.legend.background_fill_color = ELEVATED_BG\np.legend.border_line_color = INK_SOFT\np.legend.border_line_width = 1\np.legend.ncols = 4\np.legend.click_policy = \"hide\"\n\n# Style\np.title.text_font_size = \"50pt\"\np.title.text_color = INK\np.title.text_font_style = \"bold\"\n\np.xaxis.axis_label = \"Pitch Length (m)\"\np.yaxis.axis_label = \"Pitch Width (m)\"\np.xaxis.axis_label_text_font_size = \"42pt\"\np.yaxis.axis_label_text_font_size = \"42pt\"\np.xaxis.major_label_text_font_size = \"34pt\"\np.yaxis.major_label_text_font_size = \"34pt\"\np.xaxis.axis_label_text_color = INK\np.yaxis.axis_label_text_color = INK\np.xaxis.major_label_text_color = INK_SOFT\np.yaxis.major_label_text_color = INK_SOFT\n\np.xaxis.axis_line_color = INK_SOFT\np.yaxis.axis_line_color = INK_SOFT\np.xaxis.major_tick_line_color = INK_SOFT\np.yaxis.major_tick_line_color = INK_SOFT\np.xaxis.minor_tick_line_color = None\np.yaxis.minor_tick_line_color = None\n\np.grid.grid_line_color = None\n\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = None\n\n# Save interactive HTML\noutput_file(f\"plot-{THEME}.html\", title=\"scatter-pitch-events · python · bokeh · anyplot.ai\")\nsave(p)\n\n# Save PNG via Selenium; CDP override forces exact W×H viewport regardless of browser chrome\nW, H = 3200, 1800\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\ndriver.execute_cdp_cmd(\n    \"Emulation.setDeviceMetricsOverride\", {\"width\": W, \"height\": H, \"deviceScaleFactor\": 1, \"mobile\": False}\n)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}