{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "0",
   "metadata": {},
   "source": [
    "# Soccer league table\n",
    "\n",
    "**The brief:** the season is over, and the club-football newsletter wants the final Premier League table: crests,\n",
    "the usual columns, each club's last five results as form pills, and the European and relegation places marked. It\n",
    "goes out 1600 px wide in the email and as a 1080 x 1350 portrait post for Instagram. The table and results are\n",
    "ESPN's, through `sportsdataverse.soccer`; great_tables draws it with sdvplot's crests and Premier League theme."
   ]
  },
  {
   "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.soccer as soccer\n",
    "from great_tables import GT, html, loc, style\n",
    "from IPython.display import Image\n",
    "from PIL import Image as PILImage\n",
    "\n",
    "from sdvplot.great_tables import gt_row_accent, gt_save_crop, gt_sdv_logos, gt_social_crop, gt_theme_pl\n",
    "\n",
    "LEAGUE, SEASON = \"eng.1\", 2025  # ESPN names a European season by the year it starts: 2025 is 2025-26\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 standings call returns the final table. Form needs the results: ESPN's scoreboard takes a calendar year, so two\n",
    "calls (2025 and 2026) cover the season, filtered to its slug. One row per club per match gives each club's last five\n",
    "results, and the points they add up to are checked against the table."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3",
   "metadata": {},
   "outputs": [],
   "source": [
    "table = soccer.espn_soccer_standings(LEAGUE, season=SEASON).sort(\"rank\")\n",
    "\n",
    "events = []\n",
    "for year in (SEASON, SEASON + 1):\n",
    "    events += soccer.espn_soccer_scoreboard(LEAGUE, dates=year, limit=500, return_parsed=False)[\"events\"]\n",
    "events = [e for e in events if e[\"season\"][\"slug\"].startswith(f\"{SEASON}-{(SEASON + 1) % 100:02d}\")]\n",
    "\n",
    "rows = []\n",
    "for e in events:\n",
    "    first, second = e[\"competitions\"][0][\"competitors\"]\n",
    "    for me, opp in ((first, second), (second, first)):\n",
    "        rows.append({\"date\": e[\"date\"], \"team_id\": me[\"id\"], \"gf\": int(me[\"score\"]), \"ga\": int(opp[\"score\"])})\n",
    "results = pl.DataFrame(rows).with_columns(\n",
    "    result=pl.when(pl.col(\"gf\") > pl.col(\"ga\"))\n",
    "    .then(pl.lit(\"W\"))\n",
    "    .when(pl.col(\"gf\") < pl.col(\"ga\"))\n",
    "    .then(pl.lit(\"L\"))\n",
    "    .otherwise(pl.lit(\"D\"))\n",
    ")\n",
    "form = (\n",
    "    results.sort(\"date\")\n",
    "    .group_by(\"team_id\", maintain_order=True)\n",
    "    .agg(\n",
    "        form=pl.col(\"result\").tail(5).str.join(\"\"),\n",
    "        points=pl.col(\"result\").replace_strict({\"W\": 3, \"D\": 1, \"L\": 0}, return_dtype=pl.Int64).sum(),\n",
    "    )\n",
    ")\n",
    "assert table.schema[\"team_id\"] == form.schema[\"team_id\"]  # ESPN ids as strings on both sides\n",
    "table = table.join(form, on=\"team_id\").sort(\"rank\")  # a join does not promise to keep row order\n",
    "assert (table[\"points\"] == table[\"points_right\"]).all(), \"results do not add up to the table\"\n",
    "print(f\"{len(events)} matches\")\n",
    "table.select(\"rank\", \"team\", \"points\", \"form\", \"note\").head()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4",
   "metadata": {},
   "source": [
    "## 2. The first draft\n",
    "\n",
    "The standings frame, straight into great_tables."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5",
   "metadata": {},
   "outputs": [],
   "source": [
    "GT(table)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6",
   "metadata": {},
   "source": [
    "Every column ESPN sends, floats where there should be integers, and nothing a fan would recognize as a league table.\n",
    "\n",
    "## 3. A league table's columns, with crests\n",
    "\n",
    "Keep the columns a table reader expects (position, club, played, won, drawn, lost, goals for and against, goal\n",
    "difference, points) as integers, and turn the ESPN team id into the club crest with `gt_sdv_logos`\n",
    "(`league=\"soccer\"` covers every club in ESPN's soccer index). Goal difference gets an explicit sign."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7",
   "metadata": {},
   "outputs": [],
   "source": [
    "counts = [\"rank\", \"games_played\", \"wins\", \"ties\", \"losses\", \"points_for\", \"points_against\", \"points\"]\n",
    "league_table = table.select(\n",
    "    *[pl.col(c).cast(pl.Int64) for c in counts[:1]],\n",
    "    \"team_id\",\n",
    "    \"team\",\n",
    "    *[pl.col(c).cast(pl.Int64) for c in counts[1:]],\n",
    "    gd=pl.col(\"point_differential\").cast(pl.Int64),\n",
    "    form=\"form\",\n",
    "    note=pl.col(\"note\").fill_null(\"\"),\n",
    ")\n",
    "base = (\n",
    "    GT(league_table)\n",
    "    .cols_hide([\"form\", \"note\"])\n",
    "    .cols_move([\"gd\"], after=\"points_against\")\n",
    "    .pipe(gt_sdv_logos, \"team_id\", league=\"soccer\", height=24)\n",
    "    .cols_label(\n",
    "        rank=\"\",\n",
    "        team_id=\"\",\n",
    "        team=\"Club\",\n",
    "        games_played=\"P\",\n",
    "        wins=\"W\",\n",
    "        ties=\"D\",\n",
    "        losses=\"L\",\n",
    "        points_for=\"GF\",\n",
    "        points_against=\"GA\",\n",
    "        gd=\"GD\",\n",
    "        points=\"Pts\",\n",
    "    )\n",
    "    .fmt_integer(\"gd\", force_sign=True)\n",
    "    .cols_align(\"center\", [\"games_played\", \"wins\", \"ties\", \"losses\", \"points_for\", \"points_against\", \"gd\", \"points\"])\n",
    ")\n",
    "base"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8",
   "metadata": {},
   "source": [
    "## 4. Form pills\n",
    "\n",
    "The last five results are a string like \"WWDLW\". A custom `fmt` function turns each letter into a small colored\n",
    "circle (green win, grey draw, red loss): great_tables inserts what a formatter returns as HTML, so a few lines of\n",
    "inline CSS is all a pill needs."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9",
   "metadata": {},
   "outputs": [],
   "source": [
    "PILL = {\"W\": \"#00a651\", \"D\": \"#9aa0a6\", \"L\": \"#e0262f\"}\n",
    "\n",
    "\n",
    "def pills(form):\n",
    "    return \"\".join(\n",
    "        f'<span style=\"display:inline-block;width:19px;height:19px;line-height:19px;margin:0 1.5px;'\n",
    "        f\"border-radius:50%;background:{PILL[r]};color:white;font-size:10px;font-weight:700;\"\n",
    "        f'text-align:center\">{r}</span>'\n",
    "        for r in form\n",
    "    )\n",
    "\n",
    "\n",
    "with_form = base.cols_unhide(\"form\").fmt(pills, columns=\"form\").cols_label(form=\"Last 5\").cols_align(\"center\", \"form\")\n",
    "with_form"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "10",
   "metadata": {},
   "source": [
    "## 5. Places, theme and headline\n",
    "\n",
    "`gt_row_accent` draws a colored bar on the edge of each row, keyed to ESPN's `note`: Champions League, Europa\n",
    "League, Conference League and relegation, the way broadcasters mark them. The bars need a key, so the subtitle\n",
    "spells the colors out. `gt_theme_pl` gives the table the Premier League's typography and purple; the points column\n",
    "is bold and the champion's row gets a light fill."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "11",
   "metadata": {},
   "outputs": [],
   "source": [
    "PLACES = {\n",
    "    \"Champions League\": \"#1d4ed8\",\n",
    "    \"Europa League\": \"#f97316\",\n",
    "    \"Conference League Playoff Round\": \"#16a34a\",\n",
    "    \"Relegation\": \"#dc2626\",\n",
    "}\n",
    "champion, runner_up = league_table.row(0, named=True), league_table.row(1, named=True)\n",
    "key = \" &nbsp; \".join(\n",
    "    f\"<span style='color:{c}'>&#9632;</span> {name.replace(' Playoff Round', ' playoff')}\" for name, c in PLACES.items()\n",
    ")\n",
    "\n",
    "final = (\n",
    "    with_form.pipe(gt_row_accent, \"note\", palette=PLACES, width=5)\n",
    "    .tab_header(\n",
    "        title=f\"{champion['team']} are champions, {champion['points'] - runner_up['points']} points clear\",\n",
    "        subtitle=html(\n",
    "            f\"Final {SEASON}-{(SEASON + 1) % 100:02d} Premier League table. Form: last five league \"\n",
    "            f\"matches, oldest first.<br>{key}\"\n",
    "        ),\n",
    "    )\n",
    "    .tab_source_note(\"European places as ESPN lists them, including those won through the cups.\")\n",
    "    .tab_source_note(\"Data: ESPN via sportsdataverse-py  |  Table: sdvplot + great_tables\")\n",
    "    .tab_style(style.text(weight=\"bold\"), loc.body(columns=\"points\"))\n",
    "    .tab_style(style.fill(\"#f3eefa\"), loc.body(rows=[0]))\n",
    "    .pipe(gt_theme_pl)\n",
    ")\n",
    "final"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "12",
   "metadata": {},
   "source": [
    "## 6. Export for the newsletter and Instagram\n",
    "\n",
    "`gt_save_crop` renders the table in headless Chrome, trims it with an even border and scales it to the email's 1600\n",
    "px. Twenty rows make a tall table, so the social cut is Instagram's 4:5 portrait (1080 x 1350) from\n",
    "`gt_social_crop`, which pads the canvas to the ratio instead of cropping."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "13",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "great_tables Premier League 2025-26 final table with club crests, results, colored form pills for the last five matches and colored bars marking European and relegation places",
     "title": "Premier League final table with form pills"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "newsletter = gt_save_crop(final, OUT / \"premier_league_1600.png\", width=1600)\n",
    "portrait = gt_social_crop(final, OUT / \"premier_league_1080x1350.png\", aspect_ratio=\"4:5\", width=1080)\n",
    "for f in (newsletter, portrait):\n",
    "    print(Path(f).name, PILImage.open(f).size)\n",
    "Image(portrait, width=540)"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  },
  "sdvplot": {
   "description": "Build the final Premier League table with crests, form pills and European and relegation places, exported for a newsletter and as an Instagram portrait.",
   "label": "Soccer league table",
   "position": 7
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
