{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "0",
   "metadata": {},
   "source": [
    "# Rank bump chart\n",
    "\n",
    "**The brief:** a season-review piece wants the whole Premier League season in one picture: every club's league\n",
    "position, match by match, with the clubs that moved most called out and crests at the end of each line. It runs\n",
    "1600 x 900 on the blog and 1200 x 675 on X. The results are ESPN's through `sportsdataverse.soccer`; matplotlib\n",
    "draws the \"bump chart\" and sdvplot the crests and colors."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1",
   "metadata": {},
   "outputs": [],
   "source": [
    "import tempfile\n",
    "from pathlib import Path\n",
    "\n",
    "import matplotlib.patheffects as pe\n",
    "import matplotlib.pyplot as plt\n",
    "import polars as pl\n",
    "import sportsdataverse.soccer as soccer\n",
    "from IPython.display import Image\n",
    "from matplotlib.colors import to_rgb\n",
    "from PIL import Image as PILImage\n",
    "\n",
    "import sdvplot\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",
    "ESPN's scoreboard takes a calendar year, so two calls cover the season, filtered to its slug. Stacking each match's\n",
    "two sides gives one row per club per match; running totals of points, goal difference and goals give each club's\n",
    "record after every game. Ranking the clubs after the same number of games, by the league's order (points, then goal\n",
    "difference, then goals scored), gives the position. Using games played rather than calendar weeks keeps a postponed\n",
    "match from scrambling the order."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3",
   "metadata": {},
   "outputs": [],
   "source": [
    "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",
    "        team = me[\"team\"]\n",
    "        rows.append(\n",
    "            {\n",
    "                \"date\": e[\"date\"],\n",
    "                \"team_id\": me[\"id\"],\n",
    "                \"team\": team[\"abbreviation\"],\n",
    "                \"name\": team[\"displayName\"],\n",
    "                \"color\": f\"#{team['color']}\",\n",
    "                \"alt\": f\"#{team['alternateColor']}\",\n",
    "                \"gf\": int(me[\"score\"]),\n",
    "                \"ga\": int(opp[\"score\"]),\n",
    "            }\n",
    "        )\n",
    "games = (\n",
    "    pl.DataFrame(rows)\n",
    "    .sort(\"date\", \"team\")  # clubs kick off at the same time; the second key keeps one order every run\n",
    "    .with_columns(\n",
    "        game=pl.int_range(1, pl.len() + 1).over(\"team_id\"),\n",
    "        pts=pl.when(pl.col(\"gf\") > pl.col(\"ga\")).then(3).when(pl.col(\"gf\") == pl.col(\"ga\")).then(1).otherwise(0),\n",
    "    )\n",
    "    .with_columns(\n",
    "        points=pl.col(\"pts\").cum_sum().over(\"team_id\"),\n",
    "        gd=(pl.col(\"gf\") - pl.col(\"ga\")).cum_sum().over(\"team_id\"),\n",
    "        goals=pl.col(\"gf\").cum_sum().over(\"team_id\"),\n",
    "    )\n",
    "    .with_columns(\n",
    "        # rank() returns unsigned integers; make them signed now, or \"start - finish\" wraps around below zero\n",
    "        position=pl.struct(\"points\", \"gd\", \"goals\").rank(\"ordinal\", descending=True).over(\"game\").cast(pl.Int64)\n",
    "    )\n",
    ")\n",
    "final = games.filter(pl.col(\"game\") == pl.col(\"game\").max()).sort(\"position\")\n",
    "final.select(\"position\", \"team\", \"points\", \"gd\").head(5)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4",
   "metadata": {},
   "source": [
    "## 2. The first draft\n",
    "\n",
    "One line per club, matplotlib's defaults."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5",
   "metadata": {},
   "outputs": [],
   "source": [
    "fig, ax = plt.subplots(figsize=(9, 5.5))\n",
    "for (team,), g in games.sort(\"team\").group_by(\"team\", maintain_order=True):\n",
    "    ax.plot(g[\"game\"], g[\"position\"], label=team)\n",
    "ax.legend(ncol=2, fontsize=7)\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6",
   "metadata": {},
   "source": [
    "Spaghetti: twenty lines in ten recycled colors, a legend nobody can match to them, and first place at the bottom.\n",
    "\n",
    "## 3. Flip it and pick the story\n",
    "\n",
    "League tables read top down, so the y axis is inverted with a tick for every position. All twenty clubs stay in\n",
    "light grey for context, and three are drawn in their colors: the champion and the two biggest climbers, measured\n",
    "from where they stood after six games to where they finished.\n",
    "\n",
    "The colors come from the data, not sdvplot: the soccer index has no club colors yet (its `color_source` column says\n",
    "`fallback`, a stand-in palette), while ESPN's scoreboard carries each club's own colors. Two red clubs would read as\n",
    "one, so a club whose color is too close to one already used switches to its alternate color."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7",
   "metadata": {},
   "outputs": [],
   "source": [
    "start = games.filter(pl.col(\"game\") == 6).select(\"team\", start=\"position\")\n",
    "climbers = (\n",
    "    final.join(start, on=\"team\")\n",
    "    .with_columns(climb=pl.col(\"start\") - pl.col(\"position\"))\n",
    "    .sort([\"climb\", \"position\"], descending=[True, False])\n",
    "    .head(2)\n",
    ")\n",
    "focus = [final[\"team\"][0], *climbers[\"team\"]]\n",
    "print(\n",
    "    sdvplot.teams(\"soccer\")\n",
    "    .filter(pl.col(\"team_id\").is_in(final[\"team_id\"].to_list()))[\"color_source\"]\n",
    "    .unique(maintain_order=True)\n",
    "    .to_list()\n",
    ")\n",
    "colors = {}\n",
    "for team, color, alt in final.filter(pl.col(\"team\").is_in(focus)).select(\"team\", \"color\", \"alt\").iter_rows():\n",
    "    close = any(sum(abs(a - b) for a, b in zip(to_rgb(color), to_rgb(c), strict=True)) < 0.6 for c in colors.values())\n",
    "    colors[team] = alt if close else color\n",
    "\n",
    "\n",
    "def lines(ax):\n",
    "    for (team,), g in games.group_by(\"team\", maintain_order=True):\n",
    "        if team in focus:\n",
    "            ax.plot(g[\"game\"], g[\"position\"], color=colors[team], lw=3, zorder=3, solid_capstyle=\"round\")\n",
    "        else:\n",
    "            ax.plot(g[\"game\"], g[\"position\"], color=\"#d9d9d9\", lw=1.2, zorder=2)\n",
    "    ax.set_ylim(20.6, 0.4)\n",
    "    ax.set_yticks(range(1, 21))\n",
    "    ax.set_xlim(0.5, 38.5)\n",
    "    ax.set_xticks([1, 10, 20, 30, 38])\n",
    "    ax.spines[[\"top\", \"right\", \"left\"]].set_visible(False)\n",
    "    ax.tick_params(axis=\"y\", length=0)\n",
    "\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(9, 5.5))\n",
    "lines(ax)\n",
    "plt.show()\n",
    "climbers.select(\"team\", \"start\", \"position\", \"climb\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8",
   "metadata": {},
   "source": [
    "## 4. Crests at the line ends\n",
    "\n",
    "Every line ends with its club's crest, in final-table order, so the right edge reads as the final table. `add_logos`\n",
    "sizes the crests as a fraction of the axes height: twenty positions in an axes 21 units tall leave a little under\n",
    "0.05 per crest. The ESPN team ids resolve against sdvplot's soccer index. The three focus clubs keep full-strength\n",
    "crests; the rest are faded so the eye goes to the story."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9",
   "metadata": {},
   "outputs": [],
   "source": [
    "def crests(ax, x=39.6):\n",
    "    in_focus = final[\"team\"].is_in(focus)\n",
    "    for mask, alpha in ((~in_focus, 0.6), (in_focus, 1.0)):\n",
    "        sub = final.filter(mask)\n",
    "        sdvplot.add_logos(\n",
    "            ax, [x] * sub.height, sub[\"position\"], sub[\"team_id\"], league=\"soccer\", height=0.044, alpha=alpha\n",
    "        )\n",
    "    ax.set_xlim(0.5, x + 1.2)\n",
    "\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(9, 5.5))\n",
    "lines(ax)\n",
    "crests(ax)\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "10",
   "metadata": {},
   "source": [
    "## 5. Tell the story\n",
    "\n",
    "Shaded bands mark what the positions mean (the top four went to the Champions League, the bottom three down), each\n",
    "focus club gets a label where its climb began, and the title states the finding. Labels get a white halo so they\n",
    "stay readable over the grey lines."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "11",
   "metadata": {},
   "outputs": [],
   "source": [
    "GREY = \"#6b6b6b\"\n",
    "champion = final.row(0, named=True)\n",
    "big = climbers.row(0, named=True)\n",
    "\n",
    "\n",
    "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 bump(figsize=(9, 5.06), dpi=100):\n",
    "    w, h = figsize\n",
    "    fig = plt.figure(figsize=figsize, dpi=dpi, facecolor=\"white\")\n",
    "    ax = fig.add_axes((0.55 / w, 0.6 / h, 1 - 0.8 / w, 1 - 1.75 / h))\n",
    "    ax.axhspan(0.5, 4.5, color=\"#1d4ed8\", alpha=0.06, lw=0)\n",
    "    ax.axhspan(17.5, 20.5, color=\"#dc2626\", alpha=0.07, lw=0)\n",
    "    halo = [pe.withStroke(linewidth=3, foreground=\"white\")]\n",
    "    ax.text(1, 4.35, \"Champions League\", fontsize=7.5, color=\"#1d4ed8\", va=\"bottom\", path_effects=halo, zorder=4)\n",
    "    ax.text(1, 20.35, \"Relegated\", fontsize=7.5, color=\"#dc2626\", va=\"bottom\", path_effects=halo, zorder=4)\n",
    "    lines(ax)\n",
    "    crests(ax)\n",
    "    for team in focus:\n",
    "        g = games.filter((pl.col(\"team\") == team) & (pl.col(\"game\") == 6)).row(0, named=True)\n",
    "        ax.annotate(\n",
    "            team,\n",
    "            (6, g[\"position\"]),\n",
    "            xytext=(0, 7),\n",
    "            textcoords=\"offset points\",\n",
    "            ha=\"center\",\n",
    "            fontsize=9,\n",
    "            fontweight=\"bold\",\n",
    "            color=colors[team],\n",
    "            path_effects=halo,\n",
    "            zorder=5,\n",
    "        )\n",
    "    ax.tick_params(colors=\"#8a8a8a\", labelsize=8)\n",
    "    ax.set_xlabel(\"Matches played\", color=GREY, fontsize=9)\n",
    "    fig.text(\n",
    "        0.25 / w,\n",
    "        1 - 0.22 / h,\n",
    "        f\"{big['name']} climbed from {ordinal(big['start'])} to \"\n",
    "        f\"{ordinal(big['position'])}; {champion['name']} were champions\",\n",
    "        fontsize=15,\n",
    "        fontweight=\"bold\",\n",
    "        va=\"top\",\n",
    "    )\n",
    "    fig.text(\n",
    "        0.25 / w,\n",
    "        1 - 0.62 / h,\n",
    "        f\"Premier League position after each match, {SEASON}-{(SEASON + 1) % 100:02d} \"\n",
    "        \"(points, then goal difference, then goals scored).\",\n",
    "        fontsize=9.5,\n",
    "        color=GREY,\n",
    "        va=\"top\",\n",
    "    )\n",
    "    fig.text(\n",
    "        1 - 0.2 / w,\n",
    "        0.12 / h,\n",
    "        \"Data: ESPN via sportsdataverse-py  |  Chart: sdvplot\",\n",
    "        fontsize=7.5,\n",
    "        color=GREY,\n",
    "        ha=\"right\",\n",
    "    )\n",
    "    return fig\n",
    "\n",
    "\n",
    "fig = bump()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "12",
   "metadata": {},
   "source": [
    "## 6. Export for the blog and X\n",
    "\n",
    "The blog's 1600 x 900 and X's 1200 x 675 are both 16:9, so one 8 x 4.5 in figure serves both, saved at 200 and 150\n",
    "dpi. Inches times dpi gives the exact pixels; the crests scale with their axes."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "13",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Bump chart of every Premier League club's position after each match of 2025-26, the champion and the two biggest climbers in team colors, crests at the line ends",
     "title": "Premier League bump chart"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "fig = bump((8, 4.5))\n",
    "for name, dpi in {\"premier_league_bump_1600x900.png\": 200, \"premier_league_bump_1200x675.png\": 150}.items():\n",
    "    fig.savefig(OUT / name, dpi=dpi)\n",
    "    print(name, PILImage.open(OUT / name).size)\n",
    "plt.close(fig)\n",
    "Image(OUT / \"premier_league_bump_1600x900.png\", width=800)"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  },
  "sdvplot": {
   "description": "Draw a season-long Premier League position bump chart with crests at the line ends and the biggest climbers called out, exported for a blog and for X.",
   "label": "Rank bump chart",
   "position": 9
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
