{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "0",
   "metadata": {},
   "source": [
    "# Spring football\n",
    "\n",
    "The XFL (2020, 2023), the USFL (2022-23), the AAF (2019) and the UFL that the XFL and USFL merged into in 2024.\n",
    "These eight examples follow the franchises across leagues, then chart standings, scoring and colors. Game data comes\n",
    "from ESPN's XFL and UFL feeds through `sportsdataverse.football`; ESPN has no USFL or AAF feed, so those two\n",
    "leagues appear here through sdvplot's own team index only."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1",
   "metadata": {},
   "outputs": [],
   "source": [
    "import warnings\n",
    "\n",
    "import matplotlib.pyplot as plt\n",
    "import polars as pl\n",
    "from sportsdataverse.football import ufl, xfl\n",
    "\n",
    "import sdvplot\n",
    "\n",
    "CAPTION = \"Data: ESPN via sportsdataverse-py\"\n",
    "SEASONS = [(\"xfl\", 2020), (\"xfl\", 2023), (\"ufl\", 2024), (\"ufl\", 2025), (\"ufl\", 2026)]\n",
    "SCOREBOARDS = {\"xfl\": xfl.espn_xfl_scoreboard, \"ufl\": ufl.espn_ufl_scoreboard}\n",
    "WEEKS = {2020: 5}  # the 2020 XFL stopped after five weeks; every other season had ten\n",
    "\n",
    "side = [\"id\", \"display_name\", \"abbreviation\", \"color\", \"score\"]\n",
    "games = pl.concat(\n",
    "    [\n",
    "        SCOREBOARDS[league](dates=season, week=week, season_type=2).select(\n",
    "            pl.lit(league).alias(\"league\"),\n",
    "            pl.lit(season).alias(\"season\"),\n",
    "            pl.lit(week).alias(\"week\"),\n",
    "            *[f\"home_{c}\" for c in side],\n",
    "            *[f\"away_{c}\" for c in side],\n",
    "        )\n",
    "        for league, season in SEASONS\n",
    "        for week in range(1, WEEKS.get(season, 10) + 1)\n",
    "    ]\n",
    ").with_columns(pl.col(\"home_score\", \"away_score\").cast(pl.Int64))\n",
    "\n",
    "# one row per team per game, from that team's side\n",
    "teams = pl.concat(\n",
    "    [\n",
    "        games.select(\n",
    "            \"league\",\n",
    "            \"season\",\n",
    "            \"week\",\n",
    "            *[pl.col(f\"{a}_{c}\").alias(c) for c in side],\n",
    "            pl.col(f\"{b}_score\").alias(\"allowed\"),\n",
    "        )\n",
    "        for a, b in [(\"home\", \"away\"), (\"away\", \"home\")]\n",
    "    ]\n",
    ").rename({\"id\": \"team_id\", \"display_name\": \"name\"})\n",
    "records = teams.group_by(\"league\", \"season\", \"team_id\", maintain_order=True).agg(\n",
    "    name=pl.col(\"name\").last(),\n",
    "    W=(pl.col(\"score\") > pl.col(\"allowed\")).sum(),\n",
    "    L=(pl.col(\"score\") < pl.col(\"allowed\")).sum(),\n",
    "    PF=pl.col(\"score\").sum(),\n",
    "    PA=pl.col(\"allowed\").sum(),\n",
    ")\n",
    "records.group_by(\"league\", \"season\", maintain_order=True).agg(teams=pl.len(), games=pl.col(\"W\").sum())"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2",
   "metadata": {},
   "source": [
    "## 1. One id per franchise, across three leagues\n",
    "\n",
    "The scoreboards come week by week (a whole-year request returns only some of the games). ESPN kept its team ids through the merger: the UFL's XFL-side teams carry their XFL ids, and its USFL-side teams the\n",
    "ids ESPN gave them in the USFL. Each column below is one ESPN id, each row one league season, and `add_logos` with\n",
    "that row's `season` draws the mark the team used then (the Renegades: Dallas, Arlington, Dallas again)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Grid of XFL and UFL team logos, one row per league season from 2020 to 2026 and one column per ESPN team id, showing which franchises carried over into the UFL",
     "title": "XFL and UFL franchises by ESPN id"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "order = records.unique(\"team_id\", keep=\"first\", maintain_order=True)[\"team_id\"].to_list()\n",
    "column = {team: i for i, team in enumerate(order)}\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(10, 4.5))\n",
    "for team, x in column.items():\n",
    "    rows = [\n",
    "        r\n",
    "        for r, (league, season) in enumerate(SEASONS)\n",
    "        if team in records.filter(pl.col(\"league\") == league, pl.col(\"season\") == season)[\"team_id\"]\n",
    "    ]\n",
    "    ax.plot([x, x], [min(rows), max(rows)], color=\"#e3e3e3\", lw=8, solid_capstyle=\"round\", zorder=1)\n",
    "for r, (league, season) in enumerate(SEASONS):\n",
    "    ids = records.filter(pl.col(\"league\") == league, pl.col(\"season\") == season)[\"team_id\"]\n",
    "    sdvplot.add_logos(ax, [column[t] for t in ids], [r] * len(ids), ids, league=league, season=season, height=0.1)\n",
    "ax.set(xlim=(-0.6, len(order) - 0.4), ylim=(len(SEASONS) - 0.5, -0.5), xticks=[])\n",
    "ax.set_yticks(range(len(SEASONS)), [f\"{league.upper()} {season}\" for league, season in SEASONS])\n",
    "ax.spines[[\"top\", \"right\", \"bottom\", \"left\"]].set_visible(False)\n",
    "ax.set_title(\"XFL and UFL teams by ESPN team id, 2020-2026\", loc=\"left\", fontweight=\"bold\")\n",
    "fig.text(0.99, 0.01, CAPTION, ha=\"right\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4",
   "metadata": {},
   "source": [
    "One gap shows in the Houston column: ESPN gave the 2024-25 Houston Roughnecks the id of the USFL's Houston Gamblers,\n",
    "and the logo archive's only mark for that id is the 2026 Gamblers logo, so 2024 and 2025 draw it too.\n",
    "\n",
    "## 2. Abbreviations or ids, season by season\n",
    "\n",
    "ESPN's abbreviations changed with the teams (Birmingham was BIR in 2024 and BHAM in 2026; Arlington was ARL).\n",
    "sdvplot's index dates each one by the seasons ESPN's scoreboards show it, so the data's own abbreviations resolve as\n",
    "well as its ids."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5",
   "metadata": {},
   "outputs": [],
   "source": [
    "ufl_2024 = (\n",
    "    teams.filter(pl.col(\"league\") == \"ufl\", pl.col(\"season\") == 2024)\n",
    "    .unique(\"team_id\", keep=\"first\", maintain_order=True)\n",
    "    .sort(\"name\")\n",
    ")\n",
    "\n",
    "ufl_2024.select(\"name\", \"abbreviation\", \"team_id\").with_columns(\n",
    "    by_abbreviation=sdvplot.resolve(ufl_2024[\"abbreviation\"], \"ufl\", season=2024),\n",
    "    by_id=sdvplot.resolve(ufl_2024[\"team_id\"], \"ufl\", season=2024),\n",
    ")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6",
   "metadata": {},
   "source": [
    "A code no UFL team has used, such as the USFL Pittsburgh Maulers' PIT, gives `None` and one warning instead of a\n",
    "guess."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7",
   "metadata": {},
   "outputs": [],
   "source": [
    "with warnings.catch_warnings(record=True) as caught:\n",
    "    warnings.simplefilter(\"always\")\n",
    "    print(sdvplot.resolve(\"PIT\", \"ufl\", season=2024))\n",
    "print(caught[0].message)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8",
   "metadata": {},
   "source": [
    "## 3. A standings table\n",
    "\n",
    "The 2026 UFL table with great_tables: `gt_sdv_logos` for the logos, `gt_color_pills` for the point differential and\n",
    "the broadcast-style `gt_theme_scoreboard`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9",
   "metadata": {},
   "outputs": [],
   "source": [
    "from great_tables import GT\n",
    "\n",
    "from sdvplot.great_tables import gt_color_pills, gt_sdv_logos, gt_theme_scoreboard\n",
    "\n",
    "table = (\n",
    "    records.filter(pl.col(\"league\") == \"ufl\", pl.col(\"season\") == 2026)\n",
    "    .with_columns(Diff=pl.col(\"PF\") - pl.col(\"PA\"))\n",
    "    .sort([\"W\", \"Diff\", \"name\"], descending=[True, True, False])\n",
    "    .select(logo=\"team_id\", team=\"name\", W=\"W\", L=\"L\", PF=\"PF\", PA=\"PA\", Diff=\"Diff\")\n",
    ")\n",
    "limit = table[\"Diff\"].abs().max()  # pills colored on a scale centered on zero\n",
    "\n",
    "(\n",
    "    GT(table)\n",
    "    .pipe(gt_sdv_logos, \"logo\", league=\"ufl\", season=2026, height=28)\n",
    "    .pipe(gt_color_pills, \"Diff\", digits=0, domain=[-limit, limit])\n",
    "    .cols_label(logo=\"\", team=\"\")\n",
    "    .tab_header(title=\"UFL standings, 2026\", subtitle=\"Regular season\")\n",
    "    .tab_source_note(CAPTION)\n",
    "    .pipe(gt_theme_scoreboard)\n",
    ")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "10",
   "metadata": {},
   "source": [
    "## 4. Every UFL season, points for and against\n",
    "\n",
    "Points scored and allowed per game, one panel per season. `add_logos` takes the panel's season, so the Renegades\n",
    "change marks between 2025 and 2026 and the 2026 expansion teams appear only in the last panel."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "11",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Three panels for the 2024, 2025 and 2026 UFL seasons, each team's logo placed by points scored and allowed per game",
     "title": "UFL points for and against"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "ufl_seasons = records.filter(pl.col(\"league\") == \"ufl\").with_columns(\n",
    "    pf=pl.col(\"PF\") / (pl.col(\"W\") + pl.col(\"L\")), pa=pl.col(\"PA\") / (pl.col(\"W\") + pl.col(\"L\"))\n",
    ")\n",
    "\n",
    "fig, axes = plt.subplots(1, 3, figsize=(10, 4.2), sharex=True, sharey=True)\n",
    "for ax, (season, rows) in zip(axes, ufl_seasons.group_by(\"season\", maintain_order=True), strict=True):\n",
    "    season = season[0]\n",
    "    ax.axline((20, 20), slope=1, color=\"#cccccc\", lw=0.8, ls=\"--\")\n",
    "    ax.scatter(rows[\"pf\"], rows[\"pa\"], s=0)\n",
    "    sdvplot.add_logos(ax, rows[\"pf\"], rows[\"pa\"], rows[\"team_id\"], league=\"ufl\", season=season, height=0.14)\n",
    "    ax.set_title(str(season), fontweight=\"bold\")\n",
    "    ax.set_xlabel(\"Points per game\")\n",
    "axes[0].set_ylabel(\"Points allowed per game\")\n",
    "axes[0].set(xlim=(12, 30), ylim=(30, 12))  # allowed flipped: better defenses higher\n",
    "fig.suptitle(\"UFL scoring and defense by season\", x=0.01, ha=\"left\", fontweight=\"bold\")\n",
    "fig.text(0.99, 0.01, f\"{CAPTION} | dashed line: scored = allowed\", ha=\"right\", fontsize=8, color=\"grey\")\n",
    "fig.tight_layout()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "12",
   "metadata": {},
   "source": [
    "## 5. Scoring across leagues with plotnine\n",
    "\n",
    "Total points in every regular-season game, by league season, from the same scoreboards."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "13",
   "metadata": {},
   "outputs": [],
   "source": [
    "from plotnine import (\n",
    "    aes,\n",
    "    geom_boxplot,\n",
    "    geom_jitter,\n",
    "    ggplot,\n",
    "    labs,\n",
    "    scale_fill_manual,\n",
    "    scale_x_discrete,\n",
    "    theme,\n",
    "    theme_minimal,\n",
    ")\n",
    "\n",
    "totals = games.with_columns(\n",
    "    total=pl.col(\"home_score\") + pl.col(\"away_score\"),\n",
    "    label=pl.format(\"{} {}\", pl.col(\"league\").str.to_uppercase(), pl.col(\"season\")),\n",
    ")\n",
    "\n",
    "(\n",
    "    ggplot(totals.to_pandas(), aes(\"label\", \"total\", fill=\"league\"))\n",
    "    + geom_boxplot(outlier_shape=\"\", width=0.5, alpha=0.6)\n",
    "    + geom_jitter(width=0.12, height=0, size=1.2, alpha=0.5, random_state=1)\n",
    "    + scale_fill_manual({\"xfl\": \"#b8b8b8\", \"ufl\": \"#4a7fb5\"})\n",
    "    + scale_x_discrete(limits=[f\"{league.upper()} {season}\" for league, season in SEASONS])  # in time order\n",
    "    + labs(\n",
    "        x=\"\",\n",
    "        y=\"Points per game (both teams)\",\n",
    "        title=\"Total points per game in spring football\",\n",
    "        caption=f\"{CAPTION} | regular season\",\n",
    "    )\n",
    "    + theme_minimal()\n",
    "    + theme(figure_size=(9, 5), legend_position=\"none\")\n",
    ")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "14",
   "metadata": {},
   "source": [
    "## 6. An interactive Plotly chart\n",
    "\n",
    "Each 2026 UFL team's running point differential, in its colors from `team_colors`, with the logos at the end of the\n",
    "lines and hover text on every week."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "15",
   "metadata": {},
   "outputs": [],
   "source": [
    "import plotly.graph_objects as go\n",
    "\n",
    "running = (\n",
    "    teams.filter(pl.col(\"league\") == \"ufl\", pl.col(\"season\") == 2026)\n",
    "    .sort(\"week\", \"team_id\")\n",
    "    .with_columns(running=(pl.col(\"score\") - pl.col(\"allowed\")).cum_sum().over(\"team_id\"))\n",
    ")\n",
    "\n",
    "fig = go.Figure()\n",
    "for (team_id, name), rows in running.group_by(\"team_id\", \"name\", maintain_order=True):\n",
    "    fig.add_trace(\n",
    "        go.Scatter(\n",
    "            x=rows[\"week\"],\n",
    "            y=rows[\"running\"],\n",
    "            mode=\"lines+markers\",\n",
    "            name=name,\n",
    "            line={\"color\": sdvplot.team_colors(team_id, \"ufl\", season=2026), \"width\": 2},\n",
    "            hovertemplate=f\"{name}<br>week %{{x}}: %{{y:+d}}<extra></extra>\",\n",
    "        )\n",
    "    )\n",
    "fig.update_layout(\n",
    "    title=\"UFL 2026: running point differential\",\n",
    "    xaxis={\"title\": \"Week\", \"range\": [0.5, 11.2]},\n",
    "    yaxis_title=\"Point differential\",\n",
    "    showlegend=False,\n",
    "    template=\"plotly_white\",\n",
    "    width=800,\n",
    "    height=500,\n",
    ")\n",
    "ends = running.group_by(\"team_id\", maintain_order=True).last().sort(\"running\", \"team_id\")\n",
    "spots = ends[\"running\"].to_list()\n",
    "for i in range(1, len(spots)):  # nudge the logos apart where teams finished close together\n",
    "    spots[i] = max(spots[i], spots[i - 1] + 13)\n",
    "sdvplot.add_logos(fig, ends[\"week\"] + 0.6, spots, ends[\"team_id\"], league=\"ufl\", season=2026, height=0.085)\n",
    "fig"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "16",
   "metadata": {},
   "source": [
    "## 7. When sdvplot has only a fallback color\n",
    "\n",
    "Most spring-league colors in the index are fallbacks (`color_source == \"fallback\"`): colors from a colorblind-safe\n",
    "palette that keep teams apart but are not theirs. ESPN's scoreboard ships each team's own color (the `color` column\n",
    "loaded above), so this chart of the 2023 XFL takes its colors from the data and its logos from sdvplot."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "17",
   "metadata": {},
   "outputs": [],
   "source": [
    "sdvplot.teams(\"xfl\").select(\"team_id\", \"name\", \"color_primary\", \"color_source\").head(4)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "18",
   "metadata": {},
   "outputs": [],
   "source": [
    "xfl_2023 = (\n",
    "    records.filter(pl.col(\"league\") == \"xfl\", pl.col(\"season\") == 2023)\n",
    "    .join(\n",
    "        teams.filter(pl.col(\"league\") == \"xfl\", pl.col(\"season\") == 2023)\n",
    "        .select(\"team_id\", \"color\")\n",
    "        .unique(\"team_id\", keep=\"last\", maintain_order=True),\n",
    "        on=\"team_id\",\n",
    "    )\n",
    "    .with_columns(color=\"#\" + pl.col(\"color\"))\n",
    "    .sort([\"W\", \"name\"], descending=[True, False])\n",
    ")\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(8, 4.5))\n",
    "ax.bar(xfl_2023[\"team_id\"], xfl_2023[\"W\"], color=xfl_2023[\"color\"].to_list())\n",
    "sdvplot.axis_logos(ax, \"x\", league=\"xfl\", season=2023, height=0.12)\n",
    "ax.set_ylabel(\"Wins\")\n",
    "ax.spines[[\"top\", \"right\"]].set_visible(False)\n",
    "ax.set_title(\"XFL 2023 regular season wins, in ESPN's team colors\", loc=\"left\", fontweight=\"bold\")\n",
    "fig.subplots_adjust(bottom=0.2)  # room for the logos under the axis, above the caption\n",
    "fig.text(0.99, 0.01, CAPTION, ha=\"right\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "19",
   "metadata": {},
   "source": [
    "## 8. The USFL and the AAF: logos without game data\n",
    "\n",
    "ESPN has no feed for the 2022-23 USFL or the 2019 AAF, and sportsdataverse has no free source for them (its AAF\n",
    "module reads PFF, which needs a key). sdvplot still knows both leagues' teams and marks, so a chart that brings its\n",
    "own data can use them; here they are, from `teams` alone."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "20",
   "metadata": {},
   "outputs": [],
   "source": [
    "import textwrap\n",
    "\n",
    "fig, axes = plt.subplots(2, 1, figsize=(10, 3.6))\n",
    "for ax, (league, season, title) in zip(\n",
    "    axes, [(\"usfl\", None, \"USFL, 2022-2023\"), (\"aaf\", 2019, \"AAF, 2019\")], strict=True\n",
    "):\n",
    "    index = sdvplot.teams(league).sort(\"name\")\n",
    "    xs = list(range(index.height))\n",
    "    sdvplot.add_logos(ax, xs, [0.62] * index.height, index[\"team_id\"], league=league, season=season, height=0.5)\n",
    "    for x, name in zip(xs, index[\"name\"], strict=True):\n",
    "        ax.text(x, 0.08, textwrap.fill(name, 11, break_long_words=False), ha=\"center\", va=\"bottom\", fontsize=7)\n",
    "    ax.set(xlim=(-0.6, 8.6), ylim=(0, 1), title=title)\n",
    "    ax.axis(\"off\")\n",
    "fig.text(0.99, 0.01, \"Teams and marks: sdvplot team index and logo archive\", ha=\"right\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  },
  "sdvplot": {
   "description": "The XFL, USFL, AAF and UFL: franchises across leagues, standings, scoring and colors from ESPN data.",
   "label": "Spring football",
   "position": 12
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
