{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "0",
   "metadata": {},
   "source": [
    "# Women's college basketball\n",
    "\n",
    "Division I women's basketball has 364 teams, and each shares its ESPN id with the school's men's team.\n",
    "These ten examples chart the 2025-26 season: names and ids, the efficiency landscape, a conference table, game\n",
    "margins in team colors, a headshot leaderboard, a shot chart on a college court, an interactive top 25,\n",
    "seed-line tiers, the champion's tournament run and the AP poll. The data are wehoop's ESPN box scores,\n",
    "schedules, standings and shots and the SportsDataverse adjusted ratings, read from GitHub release files by\n",
    "[sportsdataverse-py](https://py.sportsdataverse.org)."
   ]
  },
  {
   "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",
    "import sportsdataverse.wbb as wbb\n",
    "\n",
    "import sdvplot\n",
    "\n",
    "SEASON = 2026  # the 2025-26 season: college seasons are named by the year they end\n",
    "CAPTION = \"Data: wehoop / ESPN via sportsdataverse-py\"\n",
    "\n",
    "ratings = wbb.load_wbb_ratings(SEASON)  # adjusted efficiency, one row per team\n",
    "box = wbb.load_wbb_team_boxscore(SEASON)  # one row per team per game\n",
    "standings = wbb.load_wbb_standings(SEASON)  # one row per team, conference and stat\n",
    "schedule = wbb.load_wbb_schedule(SEASON)  # one row per game\n",
    "ratings.height, box.height, standings.height, schedule.height"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2",
   "metadata": {},
   "source": [
    "## 1. One school id, two programs\n",
    "\n",
    "ESPN gives a school one team id for its men's and women's teams, and sdvplot keeps them as separate leagues,\n",
    "so the league key picks the program: 2633 is the Tennessee Volunteers in `\"mbb\"` and the Lady Volunteers in\n",
    "`\"wbb\"`, each with its own name and mark."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3",
   "metadata": {},
   "outputs": [],
   "source": [
    "fig, axes = plt.subplots(1, 2, figsize=(6, 3))\n",
    "for ax, league in zip(axes, (\"mbb\", \"wbb\"), strict=True):\n",
    "    name = sdvplot.teams(league).filter(pl.col(\"team_id\") == \"2633\")[\"name\"][0]\n",
    "    ax.imshow(sdvplot.logo_image(2633, league, size=200))\n",
    "    ax.set_title(f'\"{league}\": {name}', fontsize=10)\n",
    "    ax.axis(\"off\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4",
   "metadata": {},
   "source": [
    "Names resolve through the index, nicknames do not: \"Lady Vols\" gives `None` and one `SdvplotWarning`. The box\n",
    "scores also hold games against 300 non-Division I opponents, which resolve the same way, with one warning for\n",
    "the whole column. The ratings get the same treatment, and each team's 2025-26 conference comes from that\n",
    "season's standings."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5",
   "metadata": {},
   "outputs": [],
   "source": [
    "with warnings.catch_warnings(record=True) as caught:\n",
    "    warnings.simplefilter(\"always\")\n",
    "    print(sdvplot.resolve([\"UConn\", \"TENN\", \"Lady Vols\", 2579], \"wbb\"))\n",
    "    opponents = sdvplot.resolve(box[\"opponent_team_id\"].cast(pl.Utf8).unique(maintain_order=True), \"wbb\")\n",
    "    ids = sdvplot.resolve(ratings[\"team_id\"], \"wbb\")\n",
    "for w in caught:\n",
    "    print(str(w.message)[:100], \"...\")\n",
    "\n",
    "conference = standings.select(pl.col(\"team_id\").cast(pl.Utf8), conference=pl.col(\"group_name\")).unique()\n",
    "d1 = (\n",
    "    ratings.with_columns(team_id=ids)\n",
    "    .drop_nulls(\"team_id\")\n",
    "    .join(conference, on=\"team_id\", how=\"left\")\n",
    "    .join(sdvplot.teams(\"wbb\").select(\"team_id\", \"short_name\"), on=\"team_id\")\n",
    "    .with_columns(d1_rank=pl.col(\"adj_em\").rank(\"ordinal\", descending=True))\n",
    "    .sort(\"d1_rank\")\n",
    ")\n",
    "d1_ids = d1[\"team_id\"].to_list()\n",
    "d1.select(\"d1_rank\", \"team_id\", \"short_name\", \"conference\", \"adj_o\", \"adj_d\", \"adj_em\").head(5)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6",
   "metadata": {},
   "source": [
    "## 2. The efficiency landscape of the top 36\n",
    "\n",
    "Another way to keep logos legible: zoom to the teams you care about. Here the axes hold only the 36 best teams\n",
    "by adjusted efficiency margin, so every logo gets room; the subtitle gives the Division I averages, far\n",
    "below and left of every logo here. Defense is points allowed, so its axis runs downward to put good defenses on top."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Logos of the 36 best women's teams placed by adjusted offense and defense",
     "title": "Women's efficiency landscape"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "top = d1.head(36)\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(10, 6))\n",
    "ax.scatter(top[\"adj_o\"], top[\"adj_d\"], s=0)  # sets the axis limits for the logos\n",
    "sdvplot.add_logos(ax, top[\"adj_o\"], top[\"adj_d\"], top[\"team_id\"], league=\"wbb\", height=0.065)\n",
    "ax.invert_yaxis()\n",
    "ax.margins(0.06)\n",
    "ax.set_xlabel(\"Adjusted offense (points per 100 possessions)\")\n",
    "ax.set_ylabel(\"Adjusted defense (points allowed per 100)\")\n",
    "ax.set_title(\n",
    "    \"2025-26: the 36 best women's teams, offense and defense\", loc=\"left\", fontsize=14, fontweight=\"bold\", pad=22\n",
    ")\n",
    "ax.text(\n",
    "    0,\n",
    "    1.015,\n",
    "    f\"Division I averages: {d1['adj_o'].mean():.1f} offense, {d1['adj_d'].mean():.1f} defense\",\n",
    "    transform=ax.transAxes,\n",
    "    fontsize=10,\n",
    "    color=\"#555555\",\n",
    ")\n",
    "fig.text(\n",
    "    0.99, 0.01, \"Data: SportsDataverse adjusted ratings via sportsdataverse-py\", ha=\"right\", fontsize=8, color=\"grey\"\n",
    ")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8",
   "metadata": {},
   "source": [
    "## 3. A conference table in The Athletic's style\n",
    "\n",
    "The SEC's 2025-26 standings, with home and road records and the record against ranked teams. The standings\n",
    "frame is long (one row per team and stat), so pivot the numbers and the record strings separately.\n",
    "`gt_color_pills` with a fixed `domain` keeps the margin colors comparable from one table to the next."
   ]
  },
  {
   "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_merge_stack_team_color, gt_sdv_logos, gt_theme_athletic\n",
    "\n",
    "sec = standings.filter(pl.col(\"group_name\") == \"Southeastern Conference\").with_columns(pl.col(\"team_id\").cast(pl.Utf8))\n",
    "numbers = sec.filter(pl.col(\"stat_type\").is_in([\"playoffseed\", \"pointdifferential\", \"wins\", \"losses\"])).pivot(\n",
    "    on=\"stat_type\", index=\"team_id\", values=\"value\"\n",
    ")\n",
    "records = sec.filter(pl.col(\"stat_type\").is_in([\"total\", \"vsconf\", \"home\", \"road\", \"vsusarankedteams\"])).pivot(\n",
    "    on=\"stat_type\", index=\"team_id\", values=\"display_value\"\n",
    ")\n",
    "table = (\n",
    "    numbers.join(records, on=\"team_id\")\n",
    "    .join(d1.select(\"team_id\", \"short_name\"), on=\"team_id\")\n",
    "    .select(\n",
    "        seed=pl.col(\"playoffseed\").cast(pl.Int64),\n",
    "        team_id=\"team_id\",\n",
    "        short_name=\"short_name\",\n",
    "        conf=pl.col(\"vsconf\") + \" SEC\",\n",
    "        overall=\"total\",\n",
    "        home=\"home\",\n",
    "        road=\"road\",\n",
    "        ranked=\"vsusarankedteams\",\n",
    "        margin=pl.col(\"pointdifferential\") / (pl.col(\"wins\") + pl.col(\"losses\")),\n",
    "    )\n",
    "    .sort(\"seed\")\n",
    ")\n",
    "\n",
    "(\n",
    "    GT(table)\n",
    "    .pipe(gt_merge_stack_team_color, \"short_name\", \"conf\", \"team_id\", league=\"wbb\")\n",
    "    .pipe(gt_sdv_logos, \"team_id\", league=\"wbb\", height=28)\n",
    "    .pipe(gt_color_pills, \"margin\", digits=1, domain=[-35, 35])\n",
    "    .cols_label(\n",
    "        seed=\"Seed\",\n",
    "        team_id=\"\",\n",
    "        short_name=\"Team\",\n",
    "        overall=\"Overall\",\n",
    "        home=\"Home\",\n",
    "        road=\"Road\",\n",
    "        ranked=\"vs. ranked\",\n",
    "        margin=\"Margin\",\n",
    "    )\n",
    "    .tab_header(\"SEC women's standings, 2025-26\", \"Seeded for the SEC tournament; margin is points per game\")\n",
    "    .tab_source_note(CAPTION)\n",
    "    .pipe(gt_theme_athletic)\n",
    ")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "10",
   "metadata": {},
   "source": [
    "## 4. Every game's margin, in team colors (plotnine)\n",
    "\n",
    "A box plot shows a season's spread; the points on top are the games, in each team's color from\n",
    "`scale_color_sdv`. The Big Ten's 18 teams are ordered by their median margin against Division I opponents, and\n",
    "`axis_logos` labels the axis."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "11",
   "metadata": {},
   "outputs": [],
   "source": [
    "from plotnine import (\n",
    "    aes,\n",
    "    element_text,\n",
    "    geom_boxplot,\n",
    "    geom_hline,\n",
    "    geom_jitter,\n",
    "    ggplot,\n",
    "    labs,\n",
    "    scale_x_discrete,\n",
    "    theme,\n",
    "    theme_minimal,\n",
    ")\n",
    "\n",
    "from sdvplot.plotnine import axis_logos, scale_color_sdv\n",
    "\n",
    "big_ten = d1.filter(pl.col(\"conference\") == \"Big Ten Conference\")[\"team_id\"].to_list()\n",
    "margins = (\n",
    "    box.with_columns(pl.col(\"team_id\", \"opponent_team_id\").cast(pl.Utf8))\n",
    "    .filter(pl.col(\"team_id\").is_in(big_ten) & pl.col(\"opponent_team_id\").is_in(d1_ids))\n",
    "    .with_columns(margin=pl.col(\"team_score\") - pl.col(\"opponent_team_score\"))\n",
    ")\n",
    "order = margins.group_by(\"team_id\", maintain_order=True).agg(pl.col(\"margin\").median()).sort(\"margin\", descending=True)\n",
    "\n",
    "p = (\n",
    "    ggplot(margins.to_pandas(), aes(\"team_id\", \"margin\"))\n",
    "    + geom_hline(yintercept=0, color=\"#555555\")\n",
    "    + geom_boxplot(outlier_shape=\"\", width=0.6, color=\"#444444\", fill=\"white\")\n",
    "    + geom_jitter(aes(color=\"team_id\"), width=0.15, height=0, size=1.6, alpha=0.85, show_legend=False, random_state=1)\n",
    "    + scale_x_discrete(limits=order[\"team_id\"].to_list())\n",
    "    + scale_color_sdv(\"wbb\")\n",
    "    + labs(\n",
    "        x=\"\",\n",
    "        y=\"Final margin (points)\",\n",
    "        title=\"Big Ten women: every game against a Division I team, 2025-26\",\n",
    "        caption=CAPTION,\n",
    "    )\n",
    "    + theme_minimal()\n",
    "    + theme(figure_size=(10, 5.5), plot_title=element_text(weight=\"bold\", size=13))\n",
    ")\n",
    "axis_logos(p, \"x\", league=\"wbb\", height=0.06)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "12",
   "metadata": {},
   "source": [
    "## 5. A scoring leaderboard with headshots\n",
    "\n",
    "Player box scores carry ESPN athlete ids: `gt_sdv_headshots` turns them into headshots and `gt_sdv_logos` the\n",
    "team ids into logos. A light theme keeps dark logos readable. Leaders need at least 20 games."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "13",
   "metadata": {},
   "outputs": [],
   "source": [
    "from sdvplot.great_tables import gt_sdv_headshots, gt_theme_broadsheet\n",
    "\n",
    "players = wbb.load_wbb_player_boxscore(SEASON)\n",
    "leaders = (\n",
    "    players.filter(~pl.col(\"did_not_play\"))\n",
    "    .group_by(\"athlete_id\", \"athlete_display_name\", \"team_id\", maintain_order=True)\n",
    "    .agg(\n",
    "        games=pl.len(),\n",
    "        ppg=pl.col(\"points\").mean(),\n",
    "        fg=pl.col(\"field_goals_made\").sum() / pl.col(\"field_goals_attempted\").sum(),\n",
    "        three=pl.col(\"three_point_field_goals_made\").sum() / pl.col(\"three_point_field_goals_attempted\").sum(),\n",
    "        ft=pl.col(\"free_throws_made\").sum() / pl.col(\"free_throws_attempted\").sum(),\n",
    "    )\n",
    "    .filter(pl.col(\"games\") >= 20)\n",
    "    .sort(\"ppg\", descending=True)\n",
    "    .head(10)\n",
    ")\n",
    "\n",
    "(\n",
    "    GT(leaders)\n",
    "    .pipe(gt_sdv_headshots, \"athlete_id\", league=\"wbb\", height=40)\n",
    "    .pipe(gt_sdv_logos, \"team_id\", league=\"wbb\", height=28)\n",
    "    .fmt_number(\"ppg\", decimals=1)\n",
    "    .fmt_percent([\"fg\", \"three\", \"ft\"], decimals=1)\n",
    "    .cols_label(\n",
    "        athlete_id=\"\",\n",
    "        athlete_display_name=\"Player\",\n",
    "        team_id=\"Team\",\n",
    "        games=\"G\",\n",
    "        ppg=\"PPG\",\n",
    "        fg=\"FG%\",\n",
    "        three=\"3P%\",\n",
    "        ft=\"FT%\",\n",
    "    )\n",
    "    .tab_header(\"The 2025-26 Division I scoring leaders\", \"Points per game, minimum 20 games\")\n",
    "    .tab_source_note(CAPTION)\n",
    "    .pipe(gt_theme_broadsheet)\n",
    ")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "14",
   "metadata": {},
   "source": [
    "## 6. A shot chart on a college court\n",
    "\n",
    "ESPN's shot coordinates are feet from center court, the frame sportypy draws, so `surface(\"wbb\", team)` takes\n",
    "them as they are once both halves are folded onto one basket. Two cleanups first: free throws are placed at\n",
    "the rim, and a shot with no location carries a placeholder of about 215 million. The court wears the\n",
    "team's colors and the headshot sits beside the title."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "15",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Made and missed field goals of the leading women's scorer on an NCAA half court in her team's colors",
     "title": "Shot chart on a college court"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "from sdvplot.matplotlib import title_image\n",
    "\n",
    "star = leaders.row(0, named=True)\n",
    "shots = wbb.load_wbb_shots(SEASON)\n",
    "mine = shots.filter(\n",
    "    (pl.col(\"athlete_id_1\") == star[\"athlete_id\"])\n",
    "    & (pl.col(\"type_text\") != \"MadeFreeThrow\")  # placed at the rim, not where they were taken\n",
    "    & (pl.col(\"coordinate_x\").abs() <= 47)  # drops the no-location sentinel\n",
    "    & (pl.col(\"coordinate_y\").abs() <= 25)\n",
    ").with_columns(  # fold the left half onto the right-hand basket\n",
    "    x=pl.col(\"coordinate_x\").abs(),\n",
    "    y=pl.when(pl.col(\"coordinate_x\") < 0).then(-pl.col(\"coordinate_y\")).otherwise(pl.col(\"coordinate_y\")),\n",
    ")\n",
    "made, missed = mine.filter(pl.col(\"scoring_play\")), mine.filter(~pl.col(\"scoring_play\"))\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(8, 6))\n",
    "sdvplot.surface(\"wbb\", star[\"team_id\"], ax=ax, display_range=\"offense\")\n",
    "ax.scatter(\n",
    "    missed[\"x\"], missed[\"y\"], marker=\"x\", s=22, lw=1.2, color=\"#e34a33\", zorder=20, label=f\"Missed ({missed.height})\"\n",
    ")\n",
    "ax.scatter(\n",
    "    made[\"x\"], made[\"y\"], s=28, color=\"#2ca25f\", edgecolor=\"white\", lw=0.6, zorder=21, label=f\"Made ({made.height})\"\n",
    ")\n",
    "ax.legend(loc=\"lower left\", fontsize=9)\n",
    "title_image(\n",
    "    ax,\n",
    "    sdvplot.headshot_url(star[\"athlete_id\"], \"wbb\"),\n",
    "    f\"{star['athlete_display_name']}: every field-goal attempt, 2025-26\",\n",
    "    height=40,\n",
    "    fontsize=12,\n",
    "    fontweight=\"bold\",\n",
    ")\n",
    "fig.text(0.98, 0.02, CAPTION, ha=\"right\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "16",
   "metadata": {},
   "source": [
    "## 7. An interactive top 25 (Plotly)\n",
    "\n",
    "Hover a bar for the team, its conference and its rating. `axis_logos` puts the logos beside a Plotly category\n",
    "axis; the bars use `team_colors`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "17",
   "metadata": {},
   "outputs": [],
   "source": [
    "import plotly.graph_objects as go\n",
    "\n",
    "top25 = d1.head(25).reverse()  # Plotly draws horizontal bars from the bottom up\n",
    "fig = go.Figure(\n",
    "    go.Bar(\n",
    "        x=top25[\"adj_em\"],\n",
    "        y=top25[\"team_id\"],\n",
    "        orientation=\"h\",\n",
    "        marker_color=sdvplot.team_colors(top25[\"team_id\"].to_list(), \"wbb\"),\n",
    "        customdata=top25.select(\"short_name\", \"conference\").rows(),\n",
    "        hovertemplate=\"%{customdata[0]} (%{customdata[1]})<br>Adj. EM %{x:+.1f}<extra></extra>\",\n",
    "    )\n",
    ")\n",
    "fig.update_layout(\n",
    "    title=\"2025-26 women's top 25 by adjusted efficiency margin\",\n",
    "    xaxis_title=\"Points per 100 possessions better than an average Division I team\",\n",
    "    yaxis_type=\"category\",\n",
    "    template=\"plotly_white\",\n",
    "    width=760,\n",
    "    height=640,\n",
    "    margin={\"l\": 70, \"t\": 60, \"b\": 60},\n",
    ")\n",
    "sdvplot.axis_logos(fig, \"y\", league=\"wbb\", height=0.03)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "18",
   "metadata": {},
   "source": [
    "## 8. Tiers as seed lines (plotnine)\n",
    "\n",
    "The NCAA tournament seeds four teams to a line, so seed lines make natural tiers: the top four by adjusted\n",
    "efficiency margin on the 1 line, the next four on the 2 line, then 3-4 and 5-8. The plotnine `team_tiers`\n",
    "returns a ggplot."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "19",
   "metadata": {},
   "outputs": [],
   "source": [
    "from sdvplot.plotnine import team_tiers\n",
    "\n",
    "lines = d1.head(32).select(\n",
    "    team=\"team_id\",\n",
    "    tier_no=pl.when(pl.col(\"d1_rank\") <= 4)\n",
    "    .then(1)\n",
    "    .when(pl.col(\"d1_rank\") <= 8)\n",
    "    .then(2)\n",
    "    .when(pl.col(\"d1_rank\") <= 16)\n",
    "    .then(3)\n",
    "    .otherwise(4),\n",
    ")\n",
    "team_tiers(\n",
    "    lines.to_pandas(),\n",
    "    \"wbb\",\n",
    "    title=\"If the ratings seeded the 2026 tournament\",\n",
    "    subtitle=\"The top 32 by adjusted efficiency margin, four teams to a seed line\",\n",
    "    caption=\"Data: SportsDataverse adjusted ratings via sportsdataverse-py\",\n",
    "    tier_desc={1: \"1 seeds\", 2: \"2 seeds\", 3: \"3-4 seeds\", 4: \"5-8 seeds\"},\n",
    ") + theme(figure_size=(10, 6))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "20",
   "metadata": {},
   "source": [
    "## 9. March: the champion's run\n",
    "\n",
    "The schedule's `notes_headline` names each NCAA tournament game. Find the title game's winner, then chart its\n",
    "six wins: the margin of each, the opponent's logo on top, the champion's logo beside the title."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "21",
   "metadata": {},
   "outputs": [],
   "source": [
    "ncaa = schedule.filter(pl.col(\"notes_headline\").str.starts_with(\"NCAA Women's Basketball Championship\"))\n",
    "title_game = ncaa.filter(pl.col(\"notes_headline\").str.ends_with(\"National Championship\")).row(0, named=True)\n",
    "champ = title_game[\"home_id\"] if title_game[\"home_winner\"] else title_game[\"away_id\"]\n",
    "run = (\n",
    "    ncaa.filter((pl.col(\"home_id\") == champ) | (pl.col(\"away_id\") == champ))\n",
    "    .sort(\"game_date\")\n",
    "    .with_columns(home=pl.col(\"home_id\") == champ)\n",
    "    .select(\n",
    "        round=pl.col(\"notes_headline\").str.split(\" - \").list.last().str.replace(\"National Championship\", \"Title game\"),\n",
    "        opponent=pl.when(\"home\").then(\"away_id\").otherwise(\"home_id\"),\n",
    "        margin=pl.when(\"home\")\n",
    "        .then(pl.col(\"home_score\") - pl.col(\"away_score\"))\n",
    "        .otherwise(pl.col(\"away_score\") - pl.col(\"home_score\")),\n",
    "    )\n",
    ")\n",
    "champ_name = sdvplot.teams(\"wbb\").filter(pl.col(\"team_id\") == str(champ))[\"name\"][0]\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(9, 5.5))\n",
    "x = list(range(run.height))\n",
    "ax.bar(x, run[\"margin\"], color=sdvplot.team_colors(champ, \"wbb\"), width=0.65)\n",
    "sdvplot.add_logos(ax, x, run[\"margin\"] + 5, run[\"opponent\"], league=\"wbb\", height=0.11)\n",
    "for xi, m in zip(x, run[\"margin\"], strict=True):\n",
    "    ax.text(xi, m / 2, f\"+{m}\", ha=\"center\", va=\"center\", color=\"white\", fontsize=12, fontweight=\"bold\")\n",
    "ax.set_xticks(x, run[\"round\"].to_list())\n",
    "ax.set_ylim(0, run[\"margin\"].max() + 11)\n",
    "ax.set_ylabel(\"Margin of victory (points)\")\n",
    "ax.spines[[\"top\", \"right\"]].set_visible(False)\n",
    "title_image(\n",
    "    ax, champ, f\"{champ_name}: six wins to the 2026 title\", league=\"wbb\", height=34, fontsize=13, fontweight=\"bold\"\n",
    ")\n",
    "fig.text(0.99, 0.01, CAPTION, ha=\"right\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "22",
   "metadata": {},
   "source": [
    "## 10. The AP poll, week by week\n",
    "\n",
    "Each game row carries both teams' AP ranks that week (99 means unranked), so the schedule holds the whole\n",
    "poll. Take each ranked team's rank per week, keep the top 8 of the last regular-season poll, and draw a bump chart\n",
    "in team colors with the logos at the finish."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "23",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Weekly AP rank lines of the final women's top 8 in team colors, logos at the finish",
     "title": "AP poll bump chart"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "import matplotlib.dates as mdates\n",
    "\n",
    "polls = (\n",
    "    pl.concat(\n",
    "        schedule.select(\n",
    "            \"game_date\", \"season_type\", team_id=pl.col(f\"{side}_id\").cast(pl.Utf8), rank=f\"{side}_current_rank\"\n",
    "        )\n",
    "        for side in (\"home\", \"away\")\n",
    "    )\n",
    "    .filter((pl.col(\"rank\") < 99) & (pl.col(\"season_type\") == 2))  # ranked, regular season\n",
    "    .group_by(\"team_id\", week=pl.col(\"game_date\").dt.truncate(\"1w\"), maintain_order=True)\n",
    "    .agg(pl.col(\"rank\").min().cast(pl.Int64))\n",
    "    .sort(\"week\")\n",
    ")\n",
    "final = (\n",
    "    polls.group_by(\"team_id\", maintain_order=True)\n",
    "    .agg(pl.col(\"rank\").last(), pl.col(\"week\").last())\n",
    "    .sort(\"rank\")\n",
    "    .head(8)\n",
    ")\n",
    "colors = sdvplot.palette(\"wbb\", teams=final[\"team_id\"])\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(10, 6))\n",
    "for team in final[\"team_id\"]:\n",
    "    weeks = polls.filter(pl.col(\"team_id\") == team)\n",
    "    ax.plot(weeks[\"week\"], weeks[\"rank\"], color=colors[team], lw=2.4, marker=\"o\", ms=3.5)\n",
    "finish = mdates.date2num(polls[\"week\"].max()) + 9  # one x for every logo, just past the last poll\n",
    "sdvplot.add_logos(ax, [finish] * final.height, final[\"rank\"], final[\"team_id\"], league=\"wbb\", height=0.045)\n",
    "ax.invert_yaxis()\n",
    "ax.set_yticks([1, 5, 10, 15, 20])\n",
    "ax.set_ylim(polls.filter(pl.col(\"team_id\").is_in(final[\"team_id\"].to_list()))[\"rank\"].max() + 1, 0)\n",
    "ax.set_xlim(right=finish + 6)  # images do not widen the axes, so make room\n",
    "ax.xaxis.set_major_locator(mdates.MonthLocator())\n",
    "ax.xaxis.set_major_formatter(mdates.DateFormatter(\"%b\"))\n",
    "ax.set_ylabel(\"AP rank\")\n",
    "ax.spines[[\"top\", \"right\"]].set_visible(False)\n",
    "ax.set_title(\"2025-26 AP poll: the top 8 at season's end, week by week\", loc=\"left\", fontsize=14, fontweight=\"bold\")\n",
    "fig.text(0.99, 0.01, CAPTION, ha=\"right\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "sdvplot": {
   "description": "Ten worked examples for Division I women's basketball: shared ESPN ids, conference tables, a shot chart on a college court, headshot leaderboards, seed-line tiers, the title run and the AP poll.",
   "label": "Women's college basketball",
   "position": 23
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
