{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "0",
   "metadata": {},
   "source": [
    "# Head-to-head card\n",
    "\n",
    "**The brief:** Super Bowl week. The social team wants a \"tale of the tape\" card for the two teams: their regular\n",
    "seasons side by side, in each team's colors, with logos, at 1200 x 675 for X and 1080 x 1080 for Instagram. The\n",
    "numbers come from nflverse play-by-play and schedules 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",
    "from PIL import Image as PILImage\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",
    "Every stat is computed for all 32 teams, not just the two finalists, because a comparison needs context: each one\n",
    "also gets a league rank (1 is best, whichever direction \"best\" is for that stat). Points come from the schedule,\n",
    "efficiency and turnovers from the play-by-play."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3",
   "metadata": {},
   "outputs": [],
   "source": [
    "schedule = nfl.load_nfl_schedule([SEASON])\n",
    "sb = schedule.filter(pl.col(\"game_type\") == \"SB\").row(0, named=True)\n",
    "regular = schedule.filter(pl.col(\"game_type\") == \"REG\")\n",
    "sides = pl.concat(\n",
    "    [\n",
    "        regular.select(team=\"home_team\", pf=\"home_score\", pa=\"away_score\"),\n",
    "        regular.select(team=\"away_team\", pf=\"away_score\", pa=\"home_score\"),\n",
    "    ]\n",
    ")\n",
    "scoring = sides.group_by(\"team\", maintain_order=True).agg(\n",
    "    w=(pl.col(\"pf\") > pl.col(\"pa\")).sum(),\n",
    "    l=(pl.col(\"pf\") < pl.col(\"pa\")).sum(),\n",
    "    ppg=pl.col(\"pf\").mean(),\n",
    "    papg=pl.col(\"pa\").mean(),\n",
    ")\n",
    "\n",
    "pbp = nfl.load_nfl_pbp([SEASON]).filter(pl.col(\"season_type\") == \"REG\", pl.col(\"epa\").is_not_null())\n",
    "plays = pbp.filter(pl.col(\"play_type\").is_in([\"pass\", \"run\"]))\n",
    "offense = plays.group_by(\"posteam\", maintain_order=True).agg(\n",
    "    off_epa=pl.col(\"epa\").mean(),\n",
    "    pass_epa=pl.col(\"epa\").filter(pl.col(\"pass\") == 1).mean(),\n",
    "    rush_epa=pl.col(\"epa\").filter(pl.col(\"rush\") == 1).mean(),\n",
    "    giveaways=(pl.col(\"interception\") + pl.col(\"fumble_lost\")).sum(),\n",
    ")\n",
    "defense = plays.group_by(\"defteam\", maintain_order=True).agg(\n",
    "    def_epa=pl.col(\"epa\").mean(), takeaways=(pl.col(\"interception\") + pl.col(\"fumble_lost\")).sum()\n",
    ")\n",
    "teams = (\n",
    "    scoring.join(offense, left_on=\"team\", right_on=\"posteam\")\n",
    "    .join(defense, left_on=\"team\", right_on=\"defteam\")\n",
    "    .with_columns(to_margin=(pl.col(\"takeaways\") - pl.col(\"giveaways\")).cast(pl.Int64))\n",
    ")\n",
    "\n",
    "# (column, label, number format, True when bigger is better)\n",
    "STATS = [\n",
    "    (\"ppg\", \"Points per game\", \"{:.1f}\", True),\n",
    "    (\"papg\", \"Points allowed per game\", \"{:.1f}\", False),\n",
    "    (\"off_epa\", \"Offense EPA per play\", \"{:+.3f}\", True),\n",
    "    (\"def_epa\", \"Defense EPA per play allowed\", \"{:+.3f}\", False),\n",
    "    (\"pass_epa\", \"EPA per dropback\", \"{:+.3f}\", True),\n",
    "    (\"rush_epa\", \"EPA per rush\", \"{:+.3f}\", True),\n",
    "    (\"to_margin\", \"Turnover margin\", \"{:+d}\", True),\n",
    "]\n",
    "teams = teams.with_columns(\n",
    "    pl.col(col).rank(\"min\", descending=better).cast(pl.Int64).alias(f\"{col}_rank\") for col, _, _, better in STATS\n",
    ")\n",
    "pair = [sb[\"home_team\"], sb[\"away_team\"]]\n",
    "teams.filter(pl.col(\"team\").is_in(pair)).sort(\"team\").select(\"team\", \"w\", \"l\", *[c for c, *_ in STATS])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4",
   "metadata": {},
   "source": [
    "## 2. The first draft\n",
    "\n",
    "Both teams' numbers as grouped bars."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5",
   "metadata": {},
   "outputs": [],
   "source": [
    "two = teams.filter(pl.col(\"team\").is_in(pair)).sort(\"team\")\n",
    "labels = [label for _, label, _, _ in STATS]\n",
    "fig, ax = plt.subplots(figsize=(9, 5))\n",
    "for i, row in enumerate(two.iter_rows(named=True)):\n",
    "    ax.barh([y + 0.4 * i for y in range(len(STATS))], [row[c] for c, *_ in STATS], height=0.4, label=row[\"team\"])\n",
    "ax.set_yticks([y + 0.2 for y in range(len(STATS))], labels)\n",
    "ax.legend()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6",
   "metadata": {},
   "source": [
    "Useless: points per game (about 25) dwarf EPA per play (about 0.1), \"more\" is good for some rows and bad for others,\n",
    "and the default blue and orange belong to neither team.\n",
    "\n",
    "## 3. One scale: league rank\n",
    "\n",
    "Ranks put every stat on the same 1-32 scale with the same direction, so a longer bar is always better. Mirroring the\n",
    "two teams around a center column of labels (a \"butterfly\") makes each row a direct comparison; the actual value and\n",
    "the rank sit at the end of each bar."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7",
   "metadata": {},
   "outputs": [],
   "source": [
    "def ordinal(n):\n",
    "    return f\"{n}{'th' if 10 <= n % 100 <= 20 else {1: 'st', 2: 'nd', 3: 'rd'}.get(n % 10, 'th')}\"\n",
    "\n",
    "\n",
    "def butterfly(ax, colors, ink=\"#1d1d1d\", muted=\"#6b6b6b\"):\n",
    "    \"\"\"Mirrored rank bars: the first team grows left from the center labels, the second right.\"\"\"\n",
    "    rows = {row[\"team\"]: row for row in teams.filter(pl.col(\"team\").is_in(pair)).iter_rows(named=True)}\n",
    "    gap = 0.42  # half the width of the label column, in axes units\n",
    "    for y, (col, label, fmt, _) in enumerate(STATS):\n",
    "        ax.text(0, y, label, ha=\"center\", va=\"center\", fontsize=9, color=ink)\n",
    "        for side, team in zip((-1, 1), pair, strict=True):\n",
    "            rank = rows[team][f\"{col}_rank\"]\n",
    "            length = (33 - rank) / 32 * 0.55  # rank 1 is the longest bar\n",
    "            start = side * gap\n",
    "            ax.barh(y, side * length, left=start, height=0.62, color=colors[team])\n",
    "            ax.text(\n",
    "                start + side * (length + 0.02),\n",
    "                y,\n",
    "                f\"{fmt.format(rows[team][col])}  ({ordinal(rank)})\"\n",
    "                if side > 0\n",
    "                else f\"({ordinal(rank)})  {fmt.format(rows[team][col])}\",\n",
    "                ha=\"left\" if side > 0 else \"right\",\n",
    "                va=\"center\",\n",
    "                fontsize=8.5,\n",
    "                color=muted,\n",
    "            )\n",
    "    ax.set_xlim(-1.35, 1.35)\n",
    "    ax.set_ylim(len(STATS) - 0.4, -0.6)\n",
    "    ax.axis(\"off\")\n",
    "\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(9, 4.5))\n",
    "butterfly(ax, {pair[0]: \"#1f77b4\", pair[1]: \"#ff7f0e\"})\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8",
   "metadata": {},
   "source": [
    "## 4. The teams' own colors\n",
    "\n",
    "Swapping matplotlib's defaults for team colors runs into a real-world snag: both teams' primary color is the same\n",
    "navy (`#002244`), so the card would be one color on both sides. Each team's secondary color, Seattle's action green\n",
    "and New England's red, tells them apart."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9",
   "metadata": {},
   "outputs": [],
   "source": [
    "primary = dict(zip(pair, sdvplot.team_colors(pair, \"nfl\"), strict=True))\n",
    "secondary = dict(zip(pair, sdvplot.team_colors(pair, \"nfl\", which=\"secondary\"), strict=True))\n",
    "print(\"primary:\", primary, \" secondary:\", secondary)\n",
    "colors = secondary if len(set(primary.values())) == 1 else primary\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(9, 4.5))\n",
    "butterfly(ax, colors)\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "10",
   "metadata": {},
   "source": [
    "## 5. Make it a card\n",
    "\n",
    "The card is dark, which most game-week graphics are, so the logos use the `\"dark\"` variant (the mark drawn for a\n",
    "dark background, which keeps navy outlines from disappearing). Each team gets its logo and record above its side,\n",
    "the kicker names the game, and the footer the source. Positions are in inches from the edges, so one function draws\n",
    "both export sizes."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "11",
   "metadata": {},
   "outputs": [],
   "source": [
    "BG, INK, MUTED = \"#0f1923\", \"#ffffff\", \"#9fb0c3\"\n",
    "rows = {row[\"team\"]: row for row in teams.filter(pl.col(\"team\").is_in(pair)).iter_rows(named=True)}\n",
    "names = dict(sdvplot.teams(\"nfl\").select(\"abbr\", \"short_name\").iter_rows())\n",
    "\n",
    "\n",
    "def card(figsize, dpi=100):\n",
    "    w, h = figsize\n",
    "    fig = plt.figure(figsize=figsize, dpi=dpi, facecolor=BG)\n",
    "    header = 1.55  # inches for kicker, logos and records\n",
    "    ax = fig.add_axes((0.3 / w, 0.45 / h, 1 - 0.6 / w, 1 - (header + 0.55) / h), facecolor=BG)\n",
    "    butterfly(ax, colors, ink=INK, muted=MUTED)\n",
    "\n",
    "    top = fig.add_axes((0, 1 - header / h, 1, header / h), facecolor=BG)\n",
    "    top.set(xlim=(0, w), ylim=(0, header))\n",
    "    top.axis(\"off\")\n",
    "    for x, team in zip((w * 0.2, w * 0.8), pair, strict=True):\n",
    "        sdvplot.add_logos(top, [x], [header - 0.62], [team], league=\"nfl\", season=SEASON, variant=\"dark\", height=0.62)\n",
    "        top.text(\n",
    "            x,\n",
    "            0.2,\n",
    "            f\"{names[team]}  {rows[team]['w']}-{rows[team]['l']}\",\n",
    "            ha=\"center\",\n",
    "            fontsize=11,\n",
    "            fontweight=\"bold\",\n",
    "            color=colors[team],\n",
    "        )\n",
    "    top.text(w / 2, header - 0.38, \"SUPER BOWL LX\", ha=\"center\", fontsize=9, fontweight=\"bold\", color=MUTED)\n",
    "    top.text(w / 2, header - 0.75, \"Tale of the tape\", ha=\"center\", fontsize=16, fontweight=\"bold\", color=INK)\n",
    "    top.text(\n",
    "        w / 2, header - 1.05, f\"{SEASON} regular season, rank among 32 teams\", ha=\"center\", fontsize=8.5, color=MUTED\n",
    "    )\n",
    "    fig.text(\n",
    "        0.5,\n",
    "        0.12 / h,\n",
    "        \"Data: nflverse via sportsdataverse-py  |  made with sdvplot\",\n",
    "        ha=\"center\",\n",
    "        fontsize=7.5,\n",
    "        color=MUTED,\n",
    "    )\n",
    "    return fig\n",
    "\n",
    "\n",
    "fig = card((8, 4.5))\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "12",
   "metadata": {},
   "source": [
    "## 6. Export for X and Instagram\n",
    "\n",
    "The same function draws both posts: 8 x 4.5 in and 7.2 x 7.2 in at 150 dpi are exactly 1200 x 675 and 1080 x 1080.\n",
    "`savefig` needs the card's background passed again, or the margins come out white."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "13",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Dark matchup card comparing Seattle and New England's 2025 regular seasons: mirrored bars of league rank in each team's secondary color, logos and records at the top",
     "title": "Super Bowl LX tale of the tape"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "for name, size in {\n",
    "    \"sb_tale_of_the_tape_1200x675.png\": (8, 4.5),\n",
    "    \"sb_tale_of_the_tape_1080x1080.png\": (7.2, 7.2),\n",
    "}.items():\n",
    "    fig = card(size, dpi=150)\n",
    "    fig.savefig(OUT / name, dpi=150, facecolor=BG)\n",
    "    plt.close(fig)\n",
    "    print(name, PILImage.open(OUT / name).size)\n",
    "Image(OUT / \"sb_tale_of_the_tape_1200x675.png\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "14",
   "metadata": {},
   "source": [
    "The square cut:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "15",
   "metadata": {},
   "outputs": [],
   "source": [
    "Image(OUT / \"sb_tale_of_the_tape_1080x1080.png\", width=540)"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  },
  "sdvplot": {
   "description": "Make a Super Bowl tale-of-the-tape card: two teams' seasons as mirrored league-rank bars in team colors with dark-variant logos, exported at 1200x675 and 1080x1080.",
   "label": "Head-to-head card",
   "position": 10
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
