{"spec_id":"scatter-pitch-events","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nscatter-pitch-events: Soccer Pitch Event Map\nLibrary: altair 6.2.1 | Python 3.13.14\nQuality: 89/100 | Updated: 2026-06-21\n\"\"\"\n\nimport os\nimport sys\n\n\n# Remove script directory from sys.path to avoid importing local altair.py\n_script_dir = os.path.dirname(os.path.abspath(__file__))\nif _script_dir in sys.path:\n    sys.path.remove(_script_dir)\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\"\n\n# Imprint palette — canonical positions 1–4 for event categories\ncolor_domain = [\"Pass\", \"Shot\", \"Tackle\", \"Interception\"]\ncolor_range = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\"]\n\n# Data\nnp.random.seed(42)\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 = np.zeros(n_events)\ny = np.zeros(n_events)\nend_x = np.zeros(n_events)\nend_y = np.zeros(n_events)\n\nfor i, etype in enumerate(event_types):\n    if etype == \"Pass\":\n        x[i] = np.random.uniform(10, 95)\n        y[i] = np.random.uniform(5, 63)\n        end_x[i] = np.clip(x[i] + np.random.uniform(-15, 25), 0, 105)\n        end_y[i] = np.clip(y[i] + np.random.uniform(-12, 12), 0, 68)\n    elif etype == \"Shot\":\n        x[i] = np.random.uniform(60, 98)\n        y[i] = np.random.uniform(15, 53)\n        # Shorter trajectory (45%) to reduce arrow congestion near goal\n        target_x = 105\n        target_y = 34 + np.random.uniform(-4, 4)\n        end_x[i] = x[i] + 0.45 * (target_x - x[i])\n        end_y[i] = y[i] + 0.45 * (target_y - y[i])\n    elif etype == \"Tackle\":\n        x[i] = np.random.uniform(15, 80)\n        y[i] = np.random.uniform(5, 63)\n    elif etype == \"Interception\":\n        x[i] = np.random.uniform(20, 75)\n        y[i] = np.random.uniform(5, 63)\n\noutcomes = np.where(np.random.random(n_events) < 0.65, \"Successful\", \"Unsuccessful\")\ndf = pd.DataFrame({\"x\": x, \"y\": y, \"end_x\": end_x, \"end_y\": end_y, \"event_type\": event_types, \"outcome\": outcomes})\n\n# Per-type marker sizes: smaller passes reduce midfield congestion\nsize_map = {\"Pass\": 110, \"Shot\": 260, \"Tackle\": 150, \"Interception\": 150}\ndf[\"marker_size\"] = df[\"event_type\"].map(size_map)\n\n# Arrowhead positions at 85% along each trajectory\narrows_df = df[df[\"event_type\"].isin([\"Pass\", \"Shot\"])].copy()\narrow_frac = 0.85\narrows_df[\"arrow_x\"] = arrows_df[\"x\"] + arrow_frac * (arrows_df[\"end_x\"] - arrows_df[\"x\"])\narrows_df[\"arrow_y\"] = arrows_df[\"y\"] + arrow_frac * (arrows_df[\"end_y\"] - arrows_df[\"y\"])\ndx = arrows_df[\"end_x\"] - arrows_df[\"x\"]\ndy = arrows_df[\"end_y\"] - arrows_df[\"y\"]\narrows_df[\"angle\"] = np.degrees(np.arctan2(dy, dx))\n\n# Annotation: deepest shot in the attacking third\nkey_shot = df[df[\"event_type\"] == \"Shot\"].nlargest(1, \"x\").copy()\nkey_shot[\"callout\"] = \"Key shot\"\n\n# Pitch zones — green gradient with pronounced opacity to highlight attacking third\nzones_data = pd.DataFrame(\n    {\n        \"x\": [-1.5, 35, 70],\n        \"y\": [-1.5, -1.5, -1.5],\n        \"x2\": [35, 70, 106.5],\n        \"y2\": [69.5, 69.5, 69.5],\n        \"fill\": [\"#1a472a\", \"#1f5432\", \"#2d6a3f\"],\n        \"zone_opacity\": [0.20, 0.34, 0.58],\n    }\n)\n\n# Zone labels — typographic hierarchy that names each pitch third\nzone_labels_data = pd.DataFrame(\n    {\"x\": [17.5, 52.5, 87.5], \"y\": [64.8, 64.8, 64.8], \"label\": [\"Defensive Third\", \"Middle Third\", \"Attacking Third\"]}\n)\n\n# Pitch markings — standard FIFA dimensions (105m × 68m)\nlines_data = pd.DataFrame(\n    {\n        \"x\": [0, 0, 105, 0, 52.5, 0, 16.5, 16.5, 0, 5.5, 5.5, 105, 88.5, 88.5, 105, 99.5, 99.5],\n        \"y\": [0, 0, 0, 68, 0, 13.84, 13.84, 54.16, 24.84, 24.84, 43.16, 13.84, 13.84, 54.16, 24.84, 24.84, 43.16],\n        \"x2\": [105, 0, 105, 105, 52.5, 16.5, 16.5, 0, 5.5, 5.5, 0, 88.5, 88.5, 105, 99.5, 99.5, 105],\n        \"y2\": [0, 68, 68, 68, 68, 13.84, 54.16, 54.16, 24.84, 43.16, 43.16, 13.84, 54.16, 54.16, 24.84, 43.16, 43.16],\n    }\n)\n\ntheta = np.linspace(0, 2 * np.pi, 60)\ncenter_circle = pd.DataFrame({\"x\": 52.5 + 9.15 * np.cos(theta), \"y\": 34 + 9.15 * np.sin(theta), \"order\": range(60)})\n\narc_theta = np.linspace(-0.65, 0.65, 30)\nleft_arc = pd.DataFrame({\"x\": 11 + 9.15 * np.cos(arc_theta), \"y\": 34 + 9.15 * np.sin(arc_theta), \"order\": range(30)})\nright_arc = pd.DataFrame(\n    {\"x\": 94 + 9.15 * np.cos(np.pi - arc_theta), \"y\": 34 + 9.15 * np.sin(np.pi - arc_theta), \"order\": range(30)}\n)\n\ncorner_arcs = []\nfor cx, cy, t_start, t_end 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(t_start, t_end, 15)\n    corner_arcs.append(pd.DataFrame({\"x\": cx + 1 * np.cos(t), \"y\": cy + 1 * np.sin(t), \"order\": range(15)}))\n\nspots = pd.DataFrame({\"x\": [52.5, 11, 94], \"y\": [34, 34, 34]})\n\n# Shared axis config — hidden for pitch diagram\nx_axis = alt.X(\n    \"x:Q\",\n    scale=alt.Scale(domain=[-1.5, 106.5]),\n    axis=alt.Axis(title=None, labels=False, ticks=False, grid=False, domain=False),\n)\ny_axis = alt.Y(\n    \"y:Q\",\n    scale=alt.Scale(domain=[-1.5, 69.5]),\n    axis=alt.Axis(title=None, labels=False, ticks=False, grid=False, domain=False),\n)\n\n# Interactive selection: click legend to filter event types (HTML export feature)\nevent_select = alt.selection_point(fields=[\"event_type\"], bind=\"legend\")\n\n# Zone background layers — full-domain coverage for clean pitch look\nzone_layers = []\nfor _, row in zones_data.iterrows():\n    zone_layers.append(\n        alt.Chart(pd.DataFrame({\"x\": [row[\"x\"]], \"y\": [row[\"y\"]], \"x2\": [row[\"x2\"]], \"y2\": [row[\"y2\"]]}))\n        .mark_rect(color=row[\"fill\"], opacity=row[\"zone_opacity\"])\n        .encode(x=\"x:Q\", y=\"y:Q\", x2=\"x2:Q\", y2=\"y2:Q\")\n    )\n\n# Zone label layer — italic white labels establish typographic hierarchy\nzone_label_layer = (\n    alt.Chart(zone_labels_data)\n    .mark_text(fontSize=8.5, fontStyle=\"italic\", color=\"rgba(255,255,255,0.52)\", align=\"center\", baseline=\"top\")\n    .encode(\n        x=alt.X(\"x:Q\", scale=alt.Scale(domain=[-1.5, 106.5])),\n        y=alt.Y(\"y:Q\", scale=alt.Scale(domain=[-1.5, 69.5])),\n        text=\"label:N\",\n    )\n)\n\n# Pitch structure — white lines on dark green\npitch_lines = (\n    alt.Chart(lines_data)\n    .mark_rule(color=\"rgba(255,255,255,0.82)\", strokeWidth=1.8)\n    .encode(x=\"x:Q\", y=\"y:Q\", x2=\"x2:Q\", y2=\"y2:Q\")\n)\ncircle_layer = (\n    alt.Chart(center_circle)\n    .mark_line(color=\"rgba(255,255,255,0.82)\", strokeWidth=1.8, filled=False)\n    .encode(x=x_axis, y=y_axis, order=\"order:O\")\n)\nleft_arc_layer = (\n    alt.Chart(left_arc)\n    .mark_line(color=\"rgba(255,255,255,0.82)\", strokeWidth=1.8)\n    .encode(x=x_axis, y=y_axis, order=\"order:O\")\n)\nright_arc_layer = (\n    alt.Chart(right_arc)\n    .mark_line(color=\"rgba(255,255,255,0.82)\", strokeWidth=1.8)\n    .encode(x=x_axis, y=y_axis, order=\"order:O\")\n)\ncorner_layers = [\n    alt.Chart(ca).mark_line(color=\"rgba(255,255,255,0.82)\", strokeWidth=1.8).encode(x=x_axis, y=y_axis, order=\"order:O\")\n    for ca in corner_arcs\n]\nspot_layer = (\n    alt.Chart(spots).mark_point(color=\"rgba(255,255,255,0.88)\", size=45, filled=True).encode(x=x_axis, y=y_axis)\n)\n\n# Direction lines for passes and shots — filtered by interactive selection\narrow_lines = (\n    alt.Chart(arrows_df)\n    .mark_rule(strokeWidth=1.1)\n    .transform_filter(event_select)\n    .encode(\n        x=\"x:Q\",\n        y=\"y:Q\",\n        x2=\"end_x:Q\",\n        y2=\"end_y:Q\",\n        color=alt.Color(\"event_type:N\", scale=alt.Scale(domain=color_domain, range=color_range), legend=None),\n        opacity=alt.Opacity(\n            \"outcome:N\", scale=alt.Scale(domain=[\"Successful\", \"Unsuccessful\"], range=[0.52, 0.22]), legend=None\n        ),\n    )\n)\narrowheads = (\n    alt.Chart(arrows_df)\n    .mark_point(shape=\"triangle-right\", filled=True, size=90, stroke=None)\n    .transform_filter(event_select)\n    .encode(\n        x=alt.X(\"arrow_x:Q\", scale=alt.Scale(domain=[-1.5, 106.5]), axis=None),\n        y=alt.Y(\"arrow_y:Q\", scale=alt.Scale(domain=[-1.5, 69.5]), axis=None),\n        color=alt.Color(\"event_type:N\", scale=alt.Scale(domain=color_domain, range=color_range), legend=None),\n        angle=alt.Angle(\"angle:Q\", scale=alt.Scale(domain=[-180, 180], range=[-180, 180])),\n        opacity=alt.Opacity(\n            \"outcome:N\", scale=alt.Scale(domain=[\"Successful\", \"Unsuccessful\"], range=[0.82, 0.38]), legend=None\n        ),\n    )\n)\n\n# Event markers — shape + color + size + opacity encodings with legend-bound selection\nevent_points = (\n    alt.Chart(df)\n    .mark_point(filled=True, stroke=\"#ffffff\", strokeWidth=1.0)\n    .add_params(event_select)\n    .transform_filter(event_select)\n    .encode(\n        x=x_axis,\n        y=y_axis,\n        color=alt.Color(\n            \"event_type:N\",\n            scale=alt.Scale(domain=color_domain, range=color_range),\n            legend=alt.Legend(\n                title=\"Event Type\",\n                titleFontSize=13,\n                titleFontWeight=\"bold\",\n                labelFontSize=11,\n                symbolSize=180,\n                orient=\"right\",\n            ),\n        ),\n        shape=alt.Shape(\n            \"event_type:N\",\n            scale=alt.Scale(\n                domain=[\"Pass\", \"Shot\", \"Tackle\", \"Interception\"],\n                range=[\"circle\", \"triangle-right\", \"triangle-up\", \"diamond\"],\n            ),\n            legend=None,\n        ),\n        size=alt.Size(\"marker_size:Q\", scale=alt.Scale(domain=[110, 260], range=[110, 260]), legend=None),\n        opacity=alt.Opacity(\n            \"outcome:N\",\n            scale=alt.Scale(domain=[\"Successful\", \"Unsuccessful\"], range=[0.92, 0.42]),\n            legend=alt.Legend(\n                title=\"Outcome\",\n                titleFontSize=13,\n                titleFontWeight=\"bold\",\n                labelFontSize=11,\n                symbolSize=180,\n                orient=\"right\",\n            ),\n        ),\n        tooltip=[\n            alt.Tooltip(\"event_type:N\", title=\"Event\"),\n            alt.Tooltip(\"outcome:N\", title=\"Outcome\"),\n            alt.Tooltip(\"x:Q\", title=\"X (m)\", format=\".1f\"),\n            alt.Tooltip(\"y:Q\", title=\"Y (m)\", format=\".1f\"),\n        ],\n    )\n)\n\n# Annotation callout — labels the deepest shot to anchor the attacking-third story\ncallout_layer = (\n    alt.Chart(key_shot)\n    .mark_text(\n        align=\"center\", baseline=\"bottom\", fontSize=8.5, fontStyle=\"italic\", fontWeight=\"bold\", color=\"#C475FD\", dy=-8\n    )\n    .encode(\n        x=alt.X(\"x:Q\", scale=alt.Scale(domain=[-1.5, 106.5])),\n        y=alt.Y(\"y:Q\", scale=alt.Scale(domain=[-1.5, 69.5])),\n        text=\"callout:N\",\n    )\n)\n\n# Compose all layers — inner view sized to maintain FIFA 105:68 pitch proportions\ntitle_str = \"scatter-pitch-events · python · altair · anyplot.ai\"\nchart = (\n    alt.layer(\n        *zone_layers,\n        zone_label_layer,\n        pitch_lines,\n        circle_layer,\n        left_arc_layer,\n        right_arc_layer,\n        *corner_layers,\n        spot_layer,\n        arrow_lines,\n        arrowheads,\n        event_points,\n        callout_layer,\n    )\n    .properties(\n        width=480,\n        height=315,\n        background=PAGE_BG,\n        title=alt.Title(\n            title_str,\n            fontSize=16,\n            fontWeight=\"bold\",\n            color=INK,\n            subtitle=\"Match events on a FIFA-standard pitch — shots highlighted in the attacking third\",\n            subtitleFontSize=11,\n            subtitleColor=INK_SOFT,\n            subtitlePadding=6,\n        ),\n    )\n    .configure_view(fill=PAGE_BG, strokeWidth=0)\n    .configure_axis(\n        domainColor=INK_SOFT, tickColor=INK_SOFT, gridColor=INK, gridOpacity=0.12, labelColor=INK_SOFT, titleColor=INK\n    )\n    .configure_title(color=INK)\n    .configure_legend(\n        fillColor=ELEVATED_BG,\n        strokeColor=INK_SOFT,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        padding=10,\n        cornerRadius=6,\n        titlePadding=6,\n    )\n    .resolve_scale(\n        color=\"independent\", opacity=\"independent\", shape=\"independent\", angle=\"independent\", size=\"independent\"\n    )\n    .interactive()\n)\n\n# Save PNG and pad to exact 3200×1800 target\nTW, TH = 3200, 1800\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\n# Save interactive HTML\nchart.save(f\"plot-{THEME}.html\")\n"}