{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "0",
   "metadata": {},
   "source": [
    "# Men's college basketball\n",
    "\n",
    "Division I men's basketball has 365 teams in 31 conferences, and the conferences change from one season to the\n",
    "next. These ten examples chart the 2025-26 season with logos, team colors and headshots: you will resolve ESPN\n",
    "team ids, keep a 365-team chart legible, take each team's conference from the season's own standings, and finish with\n",
    "an interactive March chart. The data are hoopR's ESPN box scores, schedules and standings and the\n",
    "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.mbb as mbb\n",
    "\n",
    "import sdvplot\n",
    "\n",
    "SEASON = 2026  # the 2025-26 season: college seasons are named by the year they end\n",
    "CAPTION = \"Data: hoopR / ESPN via sportsdataverse-py\"\n",
    "\n",
    "ratings = mbb.load_mbb_ratings(SEASON)  # adjusted efficiency, one row per team\n",
    "box = mbb.load_mbb_team_boxscore(SEASON)  # one row per team per game\n",
    "standings = mbb.load_mbb_standings(SEASON)  # one row per team, conference and stat\n",
    "ratings.height, box.height, standings.height"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2",
   "metadata": {},
   "source": [
    "## 1. ESPN team ids, names and the unknown-team warning\n",
    "\n",
    "College data carries ESPN team ids, and the index's `team_id` is that id as a string, so ids, abbreviations and\n",
    "full names all resolve. Short names are where 360+ teams bite: \"St. Mary's\" matches nothing, so `resolve` gives\n",
    "`None` with one `SdvplotWarning`, and `suggest` lists the candidates."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3",
   "metadata": {},
   "outputs": [],
   "source": [
    "values = [130, \"MICH\", \"Michigan Wolverines\", \"UConn\", \"St. Mary's\"]\n",
    "with warnings.catch_warnings(record=True) as caught:\n",
    "    warnings.simplefilter(\"always\")\n",
    "    print(sdvplot.resolve(values, \"mbb\"))\n",
    "print(caught[0].message)\n",
    "sdvplot.suggest(\"St. Mary's\", \"mbb\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4",
   "metadata": {},
   "source": [
    "The ratings rate every team that played a Division I team, including 362 non-Division I opponents with one to\n",
    "three games each. The index holds the Division I programs only, so those ids come back `None` with one warning\n",
    "for the whole column; dropping them keeps the 365 Division I teams. Each team's 2025-26 conference comes from\n",
    "that season's standings (the College Basketball Crown is a postseason event, not a conference)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5",
   "metadata": {},
   "outputs": [],
   "source": [
    "with warnings.catch_warnings(record=True) as caught:\n",
    "    warnings.simplefilter(\"always\")\n",
    "    ids = sdvplot.resolve(ratings[\"team_id\"], \"mbb\")\n",
    "print(str(caught[0].message)[:110], \"...\")\n",
    "\n",
    "conference = (\n",
    "    standings.filter(pl.col(\"group_name\") != \"College Basketball Crown\")\n",
    "    .select(pl.col(\"team_id\").cast(pl.Utf8), conference=pl.col(\"group_name\"))\n",
    "    .unique()\n",
    ")\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(\"mbb\").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.select(\"d1_rank\", \"team_id\", \"short_name\", \"conference\", \"adj_o\", \"adj_d\", \"adj_em\", \"adj_tempo\").head(5)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6",
   "metadata": {},
   "source": [
    "## 2. Tempo against efficiency, with logos for the top 25\n",
    "\n",
    "With 365 teams, logos for everyone would be a smear. Draw every team as a grey point and give logos only to the\n",
    "top 25 by adjusted efficiency margin."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Scatter of 365 men's teams' adjusted tempo against efficiency margin, with logos for the 25 best",
     "title": "Tempo against efficiency, top 25 logos"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "top25 = d1.head(25)\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(10, 6))\n",
    "ax.scatter(d1[\"adj_tempo\"], d1[\"adj_em\"], s=14, color=\"#c4c4c4\", zorder=1)\n",
    "ax.axhline(0, color=\"#999999\", lw=0.8, zorder=0)\n",
    "sdvplot.add_logos(ax, top25[\"adj_tempo\"], top25[\"adj_em\"], top25[\"team_id\"], league=\"mbb\", height=0.06)\n",
    "ax.set_xlabel(\"Adjusted tempo (possessions per 40 minutes)\")\n",
    "ax.set_ylabel(\"Adjusted efficiency margin (points per 100 possessions)\")\n",
    "ax.set_title(\"The top 25 play at every pace\", loc=\"left\", fontsize=15, fontweight=\"bold\", pad=24)\n",
    "ax.text(\n",
    "    0,\n",
    "    1.015,\n",
    "    \"2025-26 Division I men's basketball; grey points are the other 340 teams\",\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 ranked top 25 with logos on the axis\n",
    "\n",
    "`axis_logos` swaps an axis' team labels for logos, so the bars keep their order and the labels stay short:\n",
    "plot the `team_id` strings as categories, then replace them."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9",
   "metadata": {},
   "outputs": [],
   "source": [
    "bars = top25.reverse()  # barh draws from the bottom up\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(8, 6))\n",
    "ax.barh(bars[\"team_id\"], bars[\"adj_em\"], color=sdvplot.team_colors(bars[\"team_id\"].to_list(), \"mbb\"))\n",
    "for y, (value, name) in enumerate(zip(bars[\"adj_em\"], bars[\"short_name\"], strict=True)):\n",
    "    ax.text(value + 0.5, y, f\"{name}  {value:.1f}\", va=\"center\", fontsize=8)\n",
    "sdvplot.axis_logos(ax, \"y\", league=\"mbb\", height=0.032)\n",
    "ax.set_xlim(0, bars[\"adj_em\"].max() * 1.25)\n",
    "ax.margins(y=0.01)\n",
    "ax.spines[[\"top\", \"right\"]].set_visible(False)\n",
    "ax.set_xlabel(\"Adjusted efficiency margin (points per 100 possessions)\")\n",
    "ax.set_title(\"2025-26 top 25 by adjusted efficiency margin\", loc=\"left\", fontweight=\"bold\")\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": "10",
   "metadata": {},
   "source": [
    "## 4. The power conferences, one panel each (plotnine)\n",
    "\n",
    "A conference is the natural small multiple: its 11 to 18 logos fit in one panel. `geom_sdv_logos` maps the team as an\n",
    "aesthetic, so it follows `facet_wrap`, and `geom_mean_lines` draws each conference's own averages. Defense is\n",
    "points allowed, so its axis is reversed to put good defenses on top."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "11",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Five plotnine panels, one per power conference, of adjusted offense against defense with team logos",
     "title": "Power conferences, offense and defense"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "from plotnine import aes, element_text, facet_wrap, ggplot, labs, scale_y_reverse, theme, theme_bw\n",
    "\n",
    "from sdvplot.plotnine import geom_mean_lines, geom_sdv_logos\n",
    "\n",
    "POWER = {\n",
    "    \"Atlantic Coast Conference\": \"ACC\",\n",
    "    \"Big 12 Conference\": \"Big 12\",\n",
    "    \"Big East Conference\": \"Big East\",\n",
    "    \"Big Ten Conference\": \"Big Ten\",\n",
    "    \"Southeastern Conference\": \"SEC\",\n",
    "}\n",
    "power = d1.filter(pl.col(\"conference\").is_in(list(POWER))).with_columns(pl.col(\"conference\").replace_strict(POWER))\n",
    "\n",
    "(\n",
    "    ggplot(power.to_pandas(), aes(\"adj_o\", \"adj_d\", team=\"team_id\", x0=\"adj_o\", y0=\"adj_d\"))\n",
    "    + geom_mean_lines(color=\"#888888\")\n",
    "    + geom_sdv_logos(league=\"mbb\", height=0.13)\n",
    "    + facet_wrap(\"conference\", ncol=3)\n",
    "    + scale_y_reverse()\n",
    "    + labs(\n",
    "        x=\"Adjusted offense (points per 100 possessions)\",\n",
    "        y=\"Adjusted defense (allowed per 100)\",\n",
    "        title=\"2025-26 power conferences: offense and defense\",\n",
    "        caption=\"Dashed lines: the conference average. Data: SportsDataverse adjusted ratings via sportsdataverse-py\",\n",
    "    )\n",
    "    + theme_bw()\n",
    "    + theme(figure_size=(10, 6), plot_title=element_text(weight=\"bold\", size=13))\n",
    ")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "12",
   "metadata": {},
   "source": [
    "## 5. Conference realignment, season by season\n",
    "\n",
    "The index's `conference` column is today's. A past season's conference comes from that season's data. Load\n",
    "three seasons of standings and follow the twelve teams of the 2023-24 Pac-12: ten left for the ACC, Big 12\n",
    "and Big Ten in 2024, Oregon State and Washington State spent two seasons in the West Coast Conference, and\n",
    "the index already lists the rebuilt Pac-12 those two anchor."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "13",
   "metadata": {},
   "outputs": [],
   "source": [
    "from great_tables import GT\n",
    "\n",
    "from sdvplot.great_tables import gt_sdv_logos, gt_theme_sdv\n",
    "\n",
    "history = mbb.load_mbb_standings([2024, 2025, 2026])\n",
    "by_season = (\n",
    "    history.filter(pl.col(\"group_name\") != \"College Basketball Crown\")\n",
    "    .select(\n",
    "        pl.col(\"season\").cast(pl.Utf8),\n",
    "        pl.col(\"team_id\").cast(pl.Utf8),  # an integer id, so the string has no \".0\"\n",
    "        pl.col(\"group_name\").str.replace(\" Conference$\", \"\"),\n",
    "    )\n",
    "    .unique()\n",
    "    .pivot(on=\"season\", index=\"team_id\", values=\"group_name\")\n",
    ")\n",
    "index = sdvplot.teams(\"mbb\").select(\"team_id\", \"short_name\", today=pl.col(\"conference\").str.replace(\" Conference$\", \"\"))\n",
    "pac12 = (\n",
    "    by_season.filter(pl.col(\"2024\") == \"Pac-12\")\n",
    "    .join(index, on=\"team_id\")\n",
    "    .select(\"team_id\", \"short_name\", \"2024\", \"2025\", \"2026\", \"today\")\n",
    "    .sort(\"2025\", \"short_name\")\n",
    ")\n",
    "\n",
    "(\n",
    "    GT(pac12)\n",
    "    .pipe(gt_sdv_logos, \"team_id\", league=\"mbb\", height=26)\n",
    "    .cols_label(\n",
    "        team_id=\"\",\n",
    "        short_name=\"Team\",\n",
    "        **{\"2024\": \"2023-24\", \"2025\": \"2024-25\", \"2026\": \"2025-26\"},\n",
    "        today=\"In the index today\",\n",
    "    )\n",
    "    .tab_header(\"Where the 2023-24 Pac-12 went\", \"Conference by season, from each season's ESPN standings\")\n",
    "    .tab_source_note(CAPTION)\n",
    "    .pipe(gt_theme_sdv)\n",
    ")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "14",
   "metadata": {},
   "source": [
    "## 6. A conference standings table with logos and team colors\n",
    "\n",
    "The Big Ten's 2025-26 standings: the conference-tournament seed, the conference and overall records, the scoring\n",
    "margin and the adjusted efficiency margin. `gt_merge_stack_team_color` puts each record under the team name in\n",
    "the team's color."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "15",
   "metadata": {},
   "outputs": [],
   "source": [
    "from sdvplot.great_tables import gt_color_pills, gt_merge_stack_team_color, gt_theme_ncaa\n",
    "\n",
    "b10 = standings.filter(pl.col(\"group_name\") == \"Big Ten Conference\").with_columns(pl.col(\"team_id\").cast(pl.Utf8))\n",
    "numbers = b10.filter(pl.col(\"stat_type\").is_in([\"playoffseed\", \"pointdifferential\", \"wins\", \"losses\"])).pivot(\n",
    "    on=\"stat_type\", index=\"team_id\", values=\"value\"\n",
    ")\n",
    "records = b10.filter(pl.col(\"stat_type\").is_in([\"total\", \"vsconf\"])).pivot(\n",
    "    on=\"stat_type\", index=\"team_id\", values=\"display_value\"\n",
    ")\n",
    "big_ten = (\n",
    "    numbers.join(records, on=\"team_id\")\n",
    "    .join(d1.select(\"team_id\", \"short_name\", \"adj_em\", \"d1_rank\"), on=\"team_id\")\n",
    "    .select(\n",
    "        seed=pl.col(\"playoffseed\").cast(pl.Int64),\n",
    "        team_id=\"team_id\",\n",
    "        short_name=\"short_name\",\n",
    "        overall=pl.col(\"total\") + \" overall\",\n",
    "        conf=\"vsconf\",\n",
    "        margin=pl.col(\"pointdifferential\") / (pl.col(\"wins\") + pl.col(\"losses\")),\n",
    "        adj_em=\"adj_em\",\n",
    "        d1_rank=\"d1_rank\",\n",
    "    )\n",
    "    .sort(\"seed\")\n",
    ")\n",
    "\n",
    "(\n",
    "    GT(big_ten)\n",
    "    .pipe(gt_merge_stack_team_color, \"short_name\", \"overall\", \"team_id\", league=\"mbb\")\n",
    "    .pipe(gt_sdv_logos, \"team_id\", league=\"mbb\", height=30)\n",
    "    .fmt_number(\"adj_em\", decimals=1, force_sign=True)\n",
    "    .pipe(gt_color_pills, \"margin\", digits=1, domain=[-18, 18])\n",
    "    .cols_label(\n",
    "        seed=\"Seed\",\n",
    "        team_id=\"\",\n",
    "        short_name=\"Team\",\n",
    "        conf=\"Big Ten\",\n",
    "        margin=\"Margin / game\",\n",
    "        adj_em=\"Adj. EM\",\n",
    "        d1_rank=\"D-I rank\",\n",
    "    )\n",
    "    .tab_header(\"Big Ten standings, 2025-26\", \"Seeded for the Big Ten tournament\")\n",
    "    .tab_source_note(CAPTION + \"; Adj. EM: SportsDataverse adjusted ratings\")\n",
    "    .pipe(gt_theme_ncaa)\n",
    ")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "16",
   "metadata": {},
   "source": [
    "## 7. Team tiers from a rating you compute\n",
    "\n",
    "Box scores are enough for a simple rating: points scored minus allowed per 100 possessions, counting only games\n",
    "between two Division I teams. Possessions use the common estimate FGA - OREB + TO + 0.475 x FTA. `team_tiers`\n",
    "draws the 32 best in tiers on sdvplotR's dark Tiermaker theme."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "17",
   "metadata": {},
   "outputs": [],
   "source": [
    "from sdvplot.matplotlib import team_tiers\n",
    "\n",
    "d1_ids = d1[\"team_id\"].to_list()\n",
    "games = box.with_columns(pl.col(\"team_id\", \"opponent_team_id\").cast(pl.Utf8)).filter(\n",
    "    pl.col(\"team_id\").is_in(d1_ids) & pl.col(\"opponent_team_id\").is_in(d1_ids)\n",
    ")\n",
    "net = (\n",
    "    games.with_columns(\n",
    "        poss=pl.col(\"field_goals_attempted\")\n",
    "        - pl.col(\"offensive_rebounds\")\n",
    "        + pl.col(\"total_turnovers\")\n",
    "        + 0.475 * pl.col(\"free_throws_attempted\")\n",
    "    )\n",
    "    .group_by(\"team_id\", maintain_order=True)\n",
    "    .agg(net=100 * (pl.col(\"team_score\").sum() - pl.col(\"opponent_team_score\").sum()) / pl.col(\"poss\").sum())\n",
    "    .sort(\"net\", descending=True)\n",
    "    .head(32)\n",
    "    .with_columns(\n",
    "        tier_no=pl.when(pl.col(\"net\") >= 22)\n",
    "        .then(1)\n",
    "        .when(pl.col(\"net\") >= 18)\n",
    "        .then(2)\n",
    "        .when(pl.col(\"net\") >= 15)\n",
    "        .then(3)\n",
    "        .otherwise(4)\n",
    "    )\n",
    "    .rename({\"team_id\": \"team\"})\n",
    ")\n",
    "\n",
    "fig = team_tiers(\n",
    "    net.to_pandas(),\n",
    "    \"mbb\",\n",
    "    title=\"2025-26 men's tiers: net points per 100 possessions\",\n",
    "    subtitle=\"Games between two Division I teams only, not adjusted for opponents\",\n",
    "    caption=CAPTION,\n",
    "    tier_desc={1: \"22+\", 2: \"18 to 22\", 3: \"15 to 18\", 4: \"Under 15\"},\n",
    ")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "18",
   "metadata": {},
   "source": [
    "## 8. Home-court edge in team colors (plotnine)\n",
    "\n",
    "A home-court edge is a team's average margin at home minus its average margin on the road. Conference games\n",
    "only, so home and road opponents come from the same league, and no neutral sites (both flags come from the\n",
    "schedule, joined on `game_id`). `scale_fill_sdv` fills each bar with the team's color and `axis_logos` labels\n",
    "the axis. Nine home and nine road games a team are a small sample, so read the order loosely."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "19",
   "metadata": {},
   "outputs": [],
   "source": [
    "from plotnine import geom_col, geom_hline, scale_x_discrete, theme_minimal\n",
    "\n",
    "from sdvplot.plotnine import axis_logos, scale_fill_sdv\n",
    "\n",
    "schedule = mbb.load_mbb_schedule(SEASON)\n",
    "assert box.schema[\"game_id\"] == schedule.schema[\"game_id\"]  # join keys of one dtype\n",
    "sec = d1.filter(pl.col(\"conference\") == \"Southeastern Conference\")[\"team_id\"].to_list()\n",
    "edge = (\n",
    "    box.join(schedule.select(\"game_id\", \"neutral_site\", \"conference_competition\"), on=\"game_id\")\n",
    "    .filter(pl.col(\"conference_competition\") & ~pl.col(\"neutral_site\"))\n",
    "    .with_columns(pl.col(\"team_id\").cast(pl.Utf8), margin=pl.col(\"team_score\") - pl.col(\"opponent_team_score\"))\n",
    "    .filter(pl.col(\"team_id\").is_in(sec))\n",
    "    .group_by(\"team_id\", maintain_order=True)\n",
    "    .agg(\n",
    "        home=pl.col(\"margin\").filter(pl.col(\"team_home_away\") == \"home\").mean(),\n",
    "        road=pl.col(\"margin\").filter(pl.col(\"team_home_away\") == \"away\").mean(),\n",
    "    )\n",
    "    .with_columns(edge=pl.col(\"home\") - pl.col(\"road\"))\n",
    "    .sort([\"edge\", \"team_id\"], descending=[True, False])\n",
    ")\n",
    "\n",
    "p = (\n",
    "    ggplot(edge.to_pandas(), aes(\"team_id\", \"edge\", fill=\"team_id\"))\n",
    "    + geom_col(show_legend=False)\n",
    "    + geom_hline(yintercept=0, color=\"#555555\")\n",
    "    + scale_x_discrete(limits=edge[\"team_id\"].to_list())\n",
    "    + scale_fill_sdv(\"mbb\")\n",
    "    + labs(\n",
    "        x=\"\",\n",
    "        y=\"Home margin minus road margin (points)\",\n",
    "        title=\"SEC home-court edge, 2025-26\",\n",
    "        caption=\"Conference games, no neutral sites. \" + CAPTION,\n",
    "    )\n",
    "    + theme_minimal()\n",
    "    + theme(figure_size=(10, 5), plot_title=element_text(weight=\"bold\", size=13))\n",
    ")\n",
    "axis_logos(p, \"x\", league=\"mbb\", height=0.07)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "20",
   "metadata": {},
   "source": [
    "## 9. Scoring leaders with headshots\n",
    "\n",
    "Player box scores carry ESPN athlete ids, which `add_headshots` turns into headshots; the team logo sits left of\n",
    "each bar. Leaders need at least 20 games."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "21",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Bar chart of the ten leading men's scorers in team colors, with headshots and team logos",
     "title": "Men's scoring leaders with headshots"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "players = mbb.load_mbb_player_boxscore(SEASON)\n",
    "leaders = (\n",
    "    players.filter(~pl.col(\"did_not_play\"))\n",
    "    .with_columns(pl.col(\"team_id\", \"athlete_id\").cast(pl.Utf8))\n",
    "    .group_by(\"athlete_id\", \"athlete_display_name\", \"team_id\", maintain_order=True)\n",
    "    .agg(games=pl.len(), ppg=pl.col(\"points\").mean())\n",
    "    .filter((pl.col(\"games\") >= 20) & pl.col(\"team_id\").is_in(d1_ids))\n",
    "    .sort(\"ppg\", descending=True)\n",
    "    .head(10)\n",
    "    .reverse()\n",
    ")\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(10, 6))\n",
    "y = list(range(leaders.height))\n",
    "ax.barh(y, leaders[\"ppg\"], color=sdvplot.team_colors(leaders[\"team_id\"].to_list(), \"mbb\"), height=0.7)\n",
    "sdvplot.add_logos(ax, [-1.6] * leaders.height, y, leaders[\"team_id\"], league=\"mbb\", height=0.07)\n",
    "sdvplot.add_headshots(ax, leaders[\"ppg\"] + 1.3, y, leaders[\"athlete_id\"], league=\"mbb\", height=0.09)\n",
    "for yi, ppg in zip(y, leaders[\"ppg\"], strict=True):\n",
    "    ax.text(ppg + 2.8, yi, f\"{ppg:.1f}\", va=\"center\", fontsize=10, fontweight=\"bold\")\n",
    "ax.set_yticks(y, leaders[\"athlete_display_name\"].to_list())\n",
    "ax.set_xlim(-3.2, leaders[\"ppg\"].max() + 4.5)\n",
    "ax.spines[[\"top\", \"right\", \"left\"]].set_visible(False)\n",
    "ax.tick_params(axis=\"y\", length=0)\n",
    "ax.set_xlabel(\"Points per game\")\n",
    "ax.set_title(\"2025-26 Division I scoring leaders\", loc=\"left\", fontsize=14, fontweight=\"bold\")\n",
    "fig.text(0.99, 0.01, \"Minimum 20 games. \" + CAPTION, ha=\"right\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "22",
   "metadata": {},
   "source": [
    "## 10. March: every team's tournament run, interactive (Altair)\n",
    "\n",
    "The schedule's `notes_headline` names each NCAA tournament game's round, so each team's last round is its exit\n",
    "(the title-game winner is the champion). An interactive chart can show all 365 teams at once: hover a point for\n",
    "the team, its conference and how far it went; drag to pan, scroll to zoom. Logos mark the Final Four."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "23",
   "metadata": {},
   "outputs": [],
   "source": [
    "import altair as alt\n",
    "\n",
    "ROUNDS = [\"First Four\", \"1st Round\", \"2nd Round\", \"Sweet 16\", \"Elite 8\", \"Final Four\", \"National Championship\"]\n",
    "ncaa = schedule.filter(pl.col(\"notes_headline\").str.starts_with(\"NCAA Men's Basketball Championship\")).with_columns(\n",
    "    round_no=pl.col(\"notes_headline\").str.split(\" - \").list.last().replace_strict(ROUNDS, list(range(7)))\n",
    ")\n",
    "sides = [\n",
    "    ncaa.select(pl.col(f\"{side}_id\").cast(pl.Utf8).alias(\"team_id\"), \"round_no\", won=pl.col(f\"{side}_winner\"))\n",
    "    for side in (\"home\", \"away\")\n",
    "]\n",
    "runs = (\n",
    "    pl.concat(sides)\n",
    "    .group_by(\"team_id\", maintain_order=True)\n",
    "    .agg(pl.col(\"round_no\").max(), champion=pl.col(\"won\").filter(pl.col(\"round_no\") == 6).any())\n",
    "    .with_columns(\n",
    "        tournament=pl.when(pl.col(\"champion\"))\n",
    "        .then(pl.lit(\"Champion\"))\n",
    "        .otherwise(pl.col(\"round_no\").replace_strict(list(range(7)), ROUNDS[:-1] + [\"Runner-up\"]))\n",
    "    )\n",
    ")\n",
    "print(runs.height, \"teams in the field;\", runs.filter(pl.col(\"champion\"))[\"team_id\"].to_list(), \"won it\")\n",
    "\n",
    "ORDER = [\n",
    "    \"Champion\",\n",
    "    \"Runner-up\",\n",
    "    \"Final Four\",\n",
    "    \"Elite 8\",\n",
    "    \"Sweet 16\",\n",
    "    \"2nd Round\",\n",
    "    \"1st Round\",\n",
    "    \"First Four\",\n",
    "    \"Not in the field\",\n",
    "]\n",
    "COLORS = [\"#0d0887\", \"#5302a3\", \"#8b0aa5\", \"#b83289\", \"#db5c68\", \"#f48849\", \"#febd2a\", \"#2a9d8f\", \"#dddddd\"]\n",
    "field = d1.join(runs.select(\"team_id\", \"tournament\"), on=\"team_id\", how=\"left\").with_columns(\n",
    "    pl.col(\"tournament\").fill_null(\"Not in the field\")\n",
    ")\n",
    "\n",
    "\n",
    "def padded(col, pad=2.5):  # a fixed domain with room for the logos at the edges\n",
    "    return [field[col].min() - pad, field[col].max() + pad]\n",
    "\n",
    "\n",
    "points = (\n",
    "    alt.Chart(\n",
    "        field.to_pandas(),\n",
    "        title=alt.Title(\n",
    "            \"2025-26: offense, defense and how far each team went\",\n",
    "            subtitle=CAPTION + \"; adjusted ratings: SportsDataverse\",\n",
    "        ),\n",
    "    )\n",
    "    .mark_circle(size=70, opacity=0.9, stroke=\"white\", strokeWidth=0.5)\n",
    "    .encode(\n",
    "        x=alt.X(\n",
    "            \"adj_o\",\n",
    "            title=\"Adjusted offense (points per 100 possessions)\",\n",
    "            scale=alt.Scale(domain=padded(\"adj_o\"), nice=False),\n",
    "        ),\n",
    "        y=alt.Y(\n",
    "            \"adj_d\",\n",
    "            title=\"Adjusted defense (allowed per 100)\",\n",
    "            scale=alt.Scale(domain=padded(\"adj_d\"), nice=False, reverse=True),\n",
    "        ),\n",
    "        color=alt.Color(\"tournament\", title=\"NCAA tournament\", sort=ORDER, scale=alt.Scale(domain=ORDER, range=COLORS)),\n",
    "        order=alt.Order(\"adj_em\"),\n",
    "        tooltip=[\"short_name\", \"conference\", \"tournament\", alt.Tooltip(\"adj_em\", format=\"+.1f\", title=\"Adj. EM\")],\n",
    "    )\n",
    "    .properties(width=620, height=420)\n",
    "    .interactive()\n",
    ")\n",
    "final_four = field.filter(pl.col(\"tournament\").is_in(ORDER[:3]))\n",
    "sdvplot.add_logos(points, final_four[\"adj_o\"], final_four[\"adj_d\"], final_four[\"team_id\"], league=\"mbb\", height=0.08)"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "sdvplot": {
   "description": "Ten worked examples for Division I men's basketball: ESPN team ids, 360+ teams, conferences by season, logos, headshots, tiers, tables and an interactive March chart.",
   "label": "Men's college basketball",
   "position": 22
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
