{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "0",
   "metadata": {},
   "source": [
    "# NFL EPA scatter\n",
    "\n",
    "**The brief:** the week after the Super Bowl, the social team wants the season's offense-vs-defense chart for X and\n",
    "Bluesky (1200 x 675) and Instagram (1080 x 1080), and it should say something about the two teams that just played.\n",
    "This is the chart every NFL analyst makes: each team's offensive EPA per play against the EPA per play its defense\n",
    "allowed, with logos for points. The data is nflverse play-by-play through `sportsdataverse.nfl`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1",
   "metadata": {},
   "outputs": [],
   "source": [
    "import tempfile\n",
    "from pathlib import Path\n",
    "\n",
    "import matplotlib.pyplot as plt\n",
    "import polars as pl\n",
    "import sportsdataverse.nfl as nfl\n",
    "from IPython.display import Image\n",
    "\n",
    "import sdvplot\n",
    "\n",
    "SEASON = 2025\n",
    "OUT = Path(tempfile.mkdtemp(prefix=\"sdvplot-recipe-\"))  # where the exports go; use your own folder"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2",
   "metadata": {},
   "source": [
    "## 1. Get the data\n",
    "\n",
    "One season of play-by-play, cut to regular-season runs and passes with an EPA value, then averaged twice: by the team\n",
    "with the ball and by the team on defense. The Super Bowl comes from the schedule, so the chart can point at it."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3",
   "metadata": {},
   "outputs": [],
   "source": [
    "pbp = nfl.load_nfl_pbp([SEASON])\n",
    "plays = pbp.filter(\n",
    "    pl.col(\"season_type\") == \"REG\",\n",
    "    pl.col(\"play_type\").is_in([\"pass\", \"run\"]),\n",
    "    pl.col(\"epa\").is_not_null(),\n",
    ")\n",
    "offense = plays.group_by(\"posteam\", maintain_order=True).agg(off_epa=pl.col(\"epa\").mean(), plays=pl.len())\n",
    "defense = plays.group_by(\"defteam\", maintain_order=True).agg(def_epa=pl.col(\"epa\").mean())\n",
    "assert offense.schema[\"posteam\"] == defense.schema[\"defteam\"]  # one dtype on both sides of the join key\n",
    "teams = (\n",
    "    offense.join(defense, left_on=\"posteam\", right_on=\"defteam\")\n",
    "    .rename({\"posteam\": \"team\"})\n",
    "    .with_columns(\n",
    "        off_rank=pl.col(\"off_epa\").rank(descending=True).cast(pl.Int64),\n",
    "        def_rank=pl.col(\"def_epa\").rank().cast(pl.Int64),  # less EPA allowed is better\n",
    "    )\n",
    "    .sort(\"team\")  # group_by returns groups in any order; sort so the logos draw in the same order every run\n",
    ")\n",
    "\n",
    "sb = nfl.load_nfl_schedule([SEASON]).filter(pl.col(\"game_type\") == \"SB\").row(0, named=True)\n",
    "winner, loser = (\n",
    "    (sb[\"home_team\"], sb[\"away_team\"]) if sb[\"home_score\"] > sb[\"away_score\"] else (sb[\"away_team\"], sb[\"home_team\"])\n",
    ")\n",
    "print(f\"Super Bowl: {sb['away_team']} {sb['away_score']}, {sb['home_team']} {sb['home_score']}\")\n",
    "teams.filter(pl.col(\"team\").is_in([winner, loser]))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4",
   "metadata": {},
   "source": [
    "The champion had the No. 2 defense and the runner-up the No. 1 offense: that is the story the chart should tell.\n",
    "\n",
    "## 2. The first draft\n",
    "\n",
    "Thirty-two dots and their abbreviations, the five-minute version."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5",
   "metadata": {},
   "outputs": [],
   "source": [
    "fig, ax = plt.subplots(figsize=(9, 6))\n",
    "ax.scatter(teams[\"off_epa\"], teams[\"def_epa\"])\n",
    "for row in teams.iter_rows(named=True):\n",
    "    ax.annotate(row[\"team\"], (row[\"off_epa\"], row[\"def_epa\"]), fontsize=8)\n",
    "ax.set(xlabel=\"Offense EPA/play\", ylabel=\"Defense EPA/play\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6",
   "metadata": {},
   "source": [
    "It is all there, but it does not read: labels collide in the middle of the pack, a reader has to work out that low\n",
    "is good on the y axis, and nothing says what the chart is about.\n",
    "\n",
    "## 3. Logos for points\n",
    "\n",
    "`add_logos` puts each team's logo on its point; the abbreviations from nflverse resolve as they are. An invisible\n",
    "scatter (`s=0`) still sets the axis limits, and `margins` leaves room so no logo at the edge is clipped. `height` is a\n",
    "fraction of the axes height, so the logos keep their size when the figure is resized or saved at another dpi."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7",
   "metadata": {},
   "outputs": [],
   "source": [
    "fig, ax = plt.subplots(figsize=(9, 6))\n",
    "ax.scatter(teams[\"off_epa\"], teams[\"def_epa\"], s=0)\n",
    "ax.margins(0.08)\n",
    "sdvplot.add_logos(ax, teams[\"off_epa\"], teams[\"def_epa\"], teams[\"team\"], league=\"nfl\", season=SEASON, height=0.07)\n",
    "ax.set(xlabel=\"Offense EPA/play\", ylabel=\"Defense EPA/play\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8",
   "metadata": {},
   "source": [
    "## 4. Point the axes the same way\n",
    "\n",
    "Readers expect \"up and to the right\" to be good. Flipping the y axis puts the best defenses on top; dashed\n",
    "league-average lines and a label in each corner turn the scatter into four quadrants anyone can read."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9",
   "metadata": {},
   "outputs": [],
   "source": [
    "def quadrants(ax):\n",
    "    \"\"\"League-average lines and a label in each corner of a flipped-defense EPA chart.\"\"\"\n",
    "    ax.axvline(teams[\"off_epa\"].mean(), color=\"#9a9a9a\", lw=0.8, ls=\"--\", zorder=1)\n",
    "    ax.axhline(teams[\"def_epa\"].mean(), color=\"#9a9a9a\", lw=0.8, ls=\"--\", zorder=1)\n",
    "    corners = {\n",
    "        (0.99, 0.99): \"Good offense, good defense\",\n",
    "        (0.01, 0.99): \"Bad offense, good defense\",\n",
    "        (0.99, 0.01): \"Good offense, bad defense\",\n",
    "        (0.01, 0.01): \"Bad offense, bad defense\",\n",
    "    }\n",
    "    for (x, y), label in corners.items():\n",
    "        ax.text(\n",
    "            x,\n",
    "            y,\n",
    "            label,\n",
    "            transform=ax.transAxes,\n",
    "            fontsize=9,\n",
    "            color=\"#8a8a8a\",\n",
    "            style=\"italic\",\n",
    "            ha=\"right\" if x > 0.5 else \"left\",\n",
    "            va=\"top\" if y > 0.5 else \"bottom\",\n",
    "        )\n",
    "\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(9, 6))\n",
    "ax.scatter(teams[\"off_epa\"], teams[\"def_epa\"], s=0)\n",
    "ax.margins(0.08)\n",
    "ax.invert_yaxis()\n",
    "quadrants(ax)\n",
    "sdvplot.add_logos(ax, teams[\"off_epa\"], teams[\"def_epa\"], teams[\"team\"], league=\"nfl\", season=SEASON, height=0.07)\n",
    "ax.set(xlabel=\"Offense EPA per play\", ylabel=\"Defense EPA per play allowed\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "10",
   "metadata": {},
   "source": [
    "Seattle is half buried: its numbers are almost the same as Jacksonville's, and the Jaguars logo covers it. That is\n",
    "the team the chart is about, so the next step has to fix it.\n",
    "\n",
    "## 5. Tell the story\n",
    "\n",
    "Now make it about the Super Bowl. The other 30 logos fade (`alpha`), the two finalists are drawn last, a little\n",
    "larger, with a callout each, which also lifts Seattle out from under Jacksonville. The title states the finding\n",
    "instead of describing the axes, the subtitle carries the definitions and the caption the source.\n",
    "\n",
    "Everything goes in one function of the figure size, ready for the exports. Font sizes are in points, so a smaller\n",
    "canvas fits fewer characters per line: the title and subtitle wrap to the width, and the margins are set in inches\n",
    "so the tick labels never collide with the edge."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "11",
   "metadata": {},
   "outputs": [],
   "source": [
    "import textwrap\n",
    "\n",
    "GREY = \"#6b6b6b\"\n",
    "TITLE = \"Super Bowl LX matched the best offense against the No. 2 defense\"\n",
    "SUBTITLE = f\"EPA per play on runs and passes, {SEASON} regular season. Defense axis flipped: up and right is better.\"\n",
    "CALLOUTS = {  # where each finalist's label sits, in points from its logo\n",
    "    winner: dict(xytext=(24, 4), ha=\"left\", va=\"center\"),\n",
    "    loser: dict(xytext=(16, 22), ha=\"right\", va=\"bottom\"),\n",
    "}\n",
    "\n",
    "\n",
    "def epa_chart(figsize, dpi=100, logo_height=0.075):\n",
    "    w, h = figsize\n",
    "    title = textwrap.fill(TITLE, int(w * 8.2))  # about 8 characters per inch at 14 pt bold\n",
    "    subtitle = textwrap.fill(SUBTITLE, int(w * 14))\n",
    "    top = 0.25 + 0.26 * title.count(\"\\n\") + 0.22 + 0.18 * (subtitle.count(\"\\n\") + 1) + 0.35  # inches\n",
    "\n",
    "    fig = plt.figure(figsize=figsize, dpi=dpi)\n",
    "    ax = fig.add_axes((0.8 / w, 0.75 / h, 1 - 1.05 / w, 1 - (0.75 + top) / h))\n",
    "    ax.scatter(teams[\"off_epa\"], teams[\"def_epa\"], s=0)\n",
    "    ax.margins(x=0.06, y=0.14)  # headroom for the corner labels and the callouts\n",
    "    ax.invert_yaxis()\n",
    "    quadrants(ax)\n",
    "\n",
    "    finalists = teams.filter(pl.col(\"team\").is_in([winner, loser]))\n",
    "    others = teams.filter(~pl.col(\"team\").is_in([winner, loser]))\n",
    "    sdvplot.add_logos(\n",
    "        ax,\n",
    "        others[\"off_epa\"],\n",
    "        others[\"def_epa\"],\n",
    "        others[\"team\"],\n",
    "        league=\"nfl\",\n",
    "        season=SEASON,\n",
    "        height=logo_height,\n",
    "        alpha=0.3,\n",
    "    )\n",
    "    sdvplot.add_logos(\n",
    "        ax,\n",
    "        finalists[\"off_epa\"],\n",
    "        finalists[\"def_epa\"],\n",
    "        finalists[\"team\"],\n",
    "        league=\"nfl\",\n",
    "        season=SEASON,\n",
    "        height=logo_height * 1.3,\n",
    "        zorder=5,\n",
    "    )\n",
    "    for row in finalists.iter_rows(named=True):\n",
    "        note = (\n",
    "            f\"Champion\\nNo. {row['def_rank']} defense\"\n",
    "            if row[\"team\"] == winner\n",
    "            else f\"Runner-up\\nNo. {row['off_rank']} offense\"\n",
    "        )\n",
    "        ax.annotate(\n",
    "            note,\n",
    "            (row[\"off_epa\"], row[\"def_epa\"]),\n",
    "            textcoords=\"offset points\",\n",
    "            fontsize=9,\n",
    "            fontweight=\"bold\",\n",
    "            color=sdvplot.team_colors(row[\"team\"], \"nfl\"),\n",
    "            linespacing=1.1,\n",
    "            **CALLOUTS[row[\"team\"]],\n",
    "        )\n",
    "\n",
    "    ax.set_xlabel(\"Offense EPA per play\", color=GREY)\n",
    "    ax.set_ylabel(\"Defense EPA per play allowed\", color=GREY)\n",
    "    ax.tick_params(colors=GREY, labelsize=8)\n",
    "    ax.spines[[\"top\", \"right\"]].set_visible(False)\n",
    "    ax.spines[[\"left\", \"bottom\"]].set_color(\"#cccccc\")\n",
    "\n",
    "    fig.text(0.25 / w, 1 - 0.25 / h, title, fontsize=14, fontweight=\"bold\", va=\"top\", linespacing=1.15)\n",
    "    fig.text(0.25 / w, 1 - (0.25 + 0.26 * title.count(\"\\n\") + 0.33) / h, subtitle, fontsize=9.5, color=GREY, va=\"top\")\n",
    "    fig.text(\n",
    "        1 - 0.2 / w,\n",
    "        0.12 / h,\n",
    "        \"Data: nflverse via sportsdataverse-py  |  Chart: sdvplot\",\n",
    "        fontsize=8,\n",
    "        color=GREY,\n",
    "        ha=\"right\",\n",
    "    )\n",
    "    return fig\n",
    "\n",
    "\n",
    "fig = epa_chart((9, 5.6))\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "12",
   "metadata": {},
   "source": [
    "## 6. Export at social sizes\n",
    "\n",
    "Social sites resize whatever they get, so export at the size they show: 1200 x 675 for X and Bluesky, 1080 x 1080\n",
    "for Instagram. Inches times dpi gives the pixels; skip `bbox_inches=\"tight\"`, which trims the canvas and breaks the\n",
    "exact size. The square's axes are taller, so the same `height` fraction would make bigger logos; it gets a smaller\n",
    "one."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "13",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Scatter of 2025 NFL offense and defense EPA per play with team logos; Seattle and New England highlighted, the other teams faded",
     "title": "NFL offense vs defense EPA, Super Bowl LX"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "from PIL import Image as PILImage\n",
    "\n",
    "files = {\n",
    "    \"nfl_epa_1200x675.png\": epa_chart((8, 4.5), dpi=150),\n",
    "    \"nfl_epa_1080x1080.png\": epa_chart((7.2, 7.2), dpi=150, logo_height=0.06),\n",
    "}\n",
    "for name, fig in files.items():\n",
    "    fig.savefig(OUT / name, dpi=150, facecolor=\"white\")\n",
    "    plt.close(fig)\n",
    "    print(name, PILImage.open(OUT / name).size)\n",
    "Image(OUT / \"nfl_epa_1200x675.png\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "14",
   "metadata": {},
   "source": [
    "The square cut, from the same function:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "15",
   "metadata": {},
   "outputs": [],
   "source": [
    "Image(OUT / \"nfl_epa_1080x1080.png\", width=540)"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  },
  "sdvplot": {
   "description": "Make the offense-vs-defense EPA chart for social after the Super Bowl, from a first draft to 1200x675 and 1080x1080 exports.",
   "label": "NFL EPA scatter",
   "position": 1
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
