{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "0",
   "metadata": {},
   "source": [
    "# CFB conference table\n",
    "\n",
    "**The brief:** the season-review newsletter needs the final Big Ten standings as an image: 1600 px wide for the email,\n",
    "plus a square cut for social. Indiana went 16-0 and won the national title, so the table should make that obvious.\n",
    "The records are built from the cfbfastR schedule through `sportsdataverse.cfb`, and the table is great_tables with\n",
    "sdvplot's logo, theme and export helpers."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1",
   "metadata": {},
   "outputs": [],
   "source": [
    "import tempfile\n",
    "from pathlib import Path\n",
    "\n",
    "import polars as pl\n",
    "import sportsdataverse.cfb as cfb\n",
    "from great_tables import GT, html, loc, nanoplot_options, style\n",
    "from IPython.display import Image\n",
    "from PIL import Image as PILImage\n",
    "\n",
    "import sdvplot\n",
    "from sdvplot.great_tables import gt_save_crop, gt_sdv_logos, gt_social_crop, gt_theme_sdv\n",
    "\n",
    "SEASON = 2025\n",
    "CONFERENCE = \"Big Ten\"\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",
    "The schedule has one row per game. Stacking the home and away sides gives one row per team per game, which makes\n",
    "every record a `group_by`. The ESPN team ids arrive as integers; they become strings once, at the boundary, because\n",
    "sdvplot's `team_id` is always a string. The margins are kept in date order as a list, one value per game, for a\n",
    "small chart later."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3",
   "metadata": {},
   "outputs": [],
   "source": [
    "schedule = cfb.load_cfb_schedule([SEASON]).filter(pl.col(\"completed\"))\n",
    "\n",
    "\n",
    "def side(me, opp):\n",
    "    return schedule.select(\n",
    "        \"start_date\",\n",
    "        \"season_type\",\n",
    "        \"conference_game\",\n",
    "        \"notes\",\n",
    "        team_id=pl.col(f\"{me}_id\").cast(pl.Utf8),\n",
    "        team=f\"{me}_team\",\n",
    "        conference=f\"{me}_conference\",\n",
    "        opponent=f\"{opp}_team\",\n",
    "        pf=f\"{me}_points\",\n",
    "        pa=f\"{opp}_points\",\n",
    "    )\n",
    "\n",
    "\n",
    "games = (\n",
    "    pl.concat([side(\"home\", \"away\"), side(\"away\", \"home\")])\n",
    "    .filter(pl.col(\"conference\") == CONFERENCE)\n",
    "    .sort(\"start_date\")\n",
    "    .with_columns(won=pl.col(\"pf\") > pl.col(\"pa\"))\n",
    ")\n",
    "in_conf = pl.col(\"conference_game\")\n",
    "standings = (\n",
    "    games.group_by(\"team_id\", \"team\", maintain_order=True)\n",
    "    .agg(\n",
    "        conf_w=(pl.col(\"won\") & in_conf).sum(),\n",
    "        conf_l=(~pl.col(\"won\") & in_conf).sum(),\n",
    "        w=pl.col(\"won\").sum(),\n",
    "        l=(~pl.col(\"won\")).sum(),\n",
    "        pf=pl.col(\"pf\").mean(),\n",
    "        pa=pl.col(\"pa\").mean(),\n",
    "        margins=pl.col(\"pf\") - pl.col(\"pa\"),\n",
    "        # the last game: where a bowl or the playoff shows up\n",
    "        last_type=pl.col(\"season_type\").last(),\n",
    "        last_won=pl.col(\"won\").last(),\n",
    "        last_score=pl.format(\"{}-{}\", pl.max_horizontal(\"pf\", \"pa\"), pl.min_horizontal(\"pf\", \"pa\")).last(),\n",
    "        last_opponent=pl.col(\"opponent\").last(),\n",
    "        last_event=pl.col(\"notes\").last(),\n",
    "    )\n",
    "    .sort([\"conf_w\", \"w\", \"team\"], descending=[True, True, False])\n",
    ")\n",
    "standings.head()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4",
   "metadata": {},
   "source": [
    "## 2. The first draft\n",
    "\n",
    "Hand the frame to great_tables as it is."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5",
   "metadata": {},
   "outputs": [],
   "source": [
    "GT(standings)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6",
   "metadata": {},
   "source": [
    "Every number is there, and none of it is readable: ids, a list printed as text, a dozen decimals and\n",
    "column names only the analyst knows.\n",
    "\n",
    "## 3. Shape it for a reader\n",
    "\n",
    "Records read as \"9-0\", not two columns. Each team's last game becomes one short line (\"W 27-21 vs Miami, CFP\n",
    "National Championship\"), which is where the national title shows up. Columns get real labels, conference and overall records sit under spanners, and the averages get\n",
    "one decimal."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7",
   "metadata": {},
   "outputs": [],
   "source": [
    "event = (\n",
    "    pl.col(\"last_event\")\n",
    "    .str.replace(\" Presented by.*\", \"\")\n",
    "    .str.replace(\" at the .*\", \"\")\n",
    "    .str.replace(\"College Football Playoff\", \"CFP\")\n",
    ")\n",
    "postseason = (\n",
    "    pl.when(pl.col(\"last_type\") == \"postseason\")\n",
    "    .then(\n",
    "        pl.format(\n",
    "            \"{} {} vs {}, {}\",\n",
    "            pl.when(\"last_won\").then(pl.lit(\"W\")).otherwise(pl.lit(\"L\")),\n",
    "            \"last_score\",\n",
    "            \"last_opponent\",\n",
    "            event,\n",
    "        )\n",
    "    )\n",
    "    .otherwise(pl.lit(\"\"))\n",
    ")\n",
    "\n",
    "table = standings.with_columns(\n",
    "    conf=pl.format(\"{}-{}\", \"conf_w\", \"conf_l\"),\n",
    "    overall=pl.format(\"{}-{}\", \"w\", \"l\"),\n",
    "    postseason=postseason,\n",
    ").select(\"team_id\", \"team\", \"conf\", \"overall\", \"pf\", \"pa\", \"margins\", \"postseason\")\n",
    "\n",
    "draft = (\n",
    "    GT(table)\n",
    "    .cols_hide([\"team_id\", \"margins\"])\n",
    "    .cols_label(team=\"Team\", conf=\"W-L\", overall=\"W-L\", pf=\"Pts/G\", pa=\"Opp/G\", postseason=\"Postseason\")\n",
    "    .tab_spanner(\"Conference\", [\"conf\"])\n",
    "    .tab_spanner(\"Overall\", [\"overall\", \"pf\", \"pa\"])\n",
    "    .fmt_number([\"pf\", \"pa\"], decimals=1)\n",
    "    .cols_align(\"center\", [\"conf\", \"overall\", \"pf\", \"pa\"])\n",
    "    .cols_align(\"left\", [\"team\", \"postseason\"])\n",
    ")\n",
    "draft"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8",
   "metadata": {},
   "source": [
    "## 4. Logos and the season at a glance\n",
    "\n",
    "`gt_sdv_logos` turns the `team_id` column into logos; the ESPN ids resolve as they are. The margins list becomes a\n",
    "nanoplot, great_tables' in-cell bar chart: one bar per game, green for a win and red for a loss, so a perfect season\n",
    "is a solid green row."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9",
   "metadata": {},
   "outputs": [],
   "source": [
    "margin_bars = nanoplot_options(\n",
    "    data_bar_fill_color=\"#2e8540\",\n",
    "    data_bar_negative_fill_color=\"#c0392b\",\n",
    "    data_bar_stroke_color=\"transparent\",\n",
    "    data_bar_negative_stroke_color=\"transparent\",\n",
    "    show_data_points=False,\n",
    "    show_reference_line=False,\n",
    "    show_vertical_guides=False,\n",
    "    show_y_axis_guide=False,\n",
    "    interactive_data_values=True,  # values on hover only, so the saved image stays clean\n",
    ")\n",
    "with_marks = (\n",
    "    draft.cols_unhide([\"team_id\", \"margins\"])\n",
    "    .pipe(gt_sdv_logos, \"team_id\", league=\"cfb\", season=SEASON, height=26)\n",
    "    .fmt_nanoplot(\"margins\", plot_type=\"bar\", autoscale=True, options=margin_bars)\n",
    "    .cols_label(team_id=\"\", margins=\"Game by game\")\n",
    "    .tab_spanner(\"Margin\", [\"margins\"])\n",
    ")\n",
    "with_marks"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "10",
   "metadata": {},
   "source": [
    "## 5. Theme it and say what it means\n",
    "\n",
    "A theme does the typography and rules in one call (`gt_theme_sdv`, the SportsDataverse house style, here). The title says the news, the subtitle\n",
    "how to read the table, and the source note credits the data. Indiana's row gets a soft fill in its own red, and a\n",
    "footnote owns up to the ordering: teams tied on conference record are listed by overall record, which is not the\n",
    "conference's tiebreaker."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "11",
   "metadata": {},
   "outputs": [],
   "source": [
    "indiana = standings.filter(pl.col(\"team\") == \"Indiana\")\n",
    "fill = sdvplot.team_colors(indiana[\"team_id\"][0], \"cfb\") + \"1f\"  # the primary color at 12% opacity\n",
    "\n",
    "final = (\n",
    "    with_marks.tab_header(\n",
    "        title=f\"Indiana ran the table: 9-0 in the {CONFERENCE}, 16-0 overall and national champion\",\n",
    "        subtitle=html(\n",
    "            f\"Final {SEASON} {CONFERENCE} standings. Bars are each game's margin, in date order: \"\n",
    "            \"<span style='color:#2e8540'><b>wins</b></span> and \"\n",
    "            \"<span style='color:#c0392b'><b>losses</b></span>.\"\n",
    "        ),\n",
    "    )\n",
    "    .tab_source_note(\"Data: cfbfastR via sportsdataverse-py  |  Table: sdvplot + great_tables\")\n",
    "    .tab_footnote(\n",
    "        \"Teams tied on conference record are listed by overall record, then by name.\",\n",
    "        locations=loc.column_labels(columns=\"conf\"),\n",
    "    )\n",
    "    .tab_style(style.fill(fill), loc.body(rows=pl.col(\"team\") == \"Indiana\"))\n",
    "    .tab_style(style.text(weight=\"bold\"), loc.body(columns=\"team\", rows=pl.col(\"team\") == \"Indiana\"))\n",
    "    .pipe(gt_theme_sdv)\n",
    ")\n",
    "final"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "12",
   "metadata": {},
   "source": [
    "## 6. Export for the newsletter and for social\n",
    "\n",
    "`gt_save_crop` renders the table in headless Chrome, trims it with an even border and, with `width=`, scales it to\n",
    "the email's 1600 px. `gt_social_crop` centers the same table on a square canvas for Instagram, never cropping it:\n",
    "a tall table just gets side padding."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "13",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "great_tables standings of the 2025 Big Ten with team logos, records and a bar per game, Indiana highlighted as 16-0 national champion",
     "title": "Big Ten final standings table"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "newsletter = gt_save_crop(final, OUT / \"big_ten_1600.png\", width=1600)\n",
    "square = gt_social_crop(final, OUT / \"big_ten_1080x1080.png\", aspect_ratio=\"1:1\", width=1080)\n",
    "for f in (newsletter, square):\n",
    "    print(Path(f).name, PILImage.open(f).size)\n",
    "Image(newsletter, width=800)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "14",
   "metadata": {},
   "source": [
    "The square cut keeps the whole table and pads the sides:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "15",
   "metadata": {},
   "outputs": [],
   "source": [
    "Image(square, width=540)"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  },
  "sdvplot": {
   "description": "Build the final Big Ten standings table for a newsletter with logos, records and game-by-game bars, exported at 1600 px and as a square for social.",
   "label": "CFB conference table",
   "position": 2
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
