{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "0",
   "metadata": {},
   "source": [
    "# NBA and G League\n",
    "\n",
    "Ten charts and tables from one NBA season, built on hoopR's ESPN data that sportsdataverse-py loads from release files\n",
    "on GitHub (no stats.nba.com calls). You'll make team-rating scatters and bars with logos, a standings bump chart, a\n",
    "headshot leaderboard, a shot chart on a team-colored court, a standings table and an interactive chart, and finish\n",
    "with the NBA G League."
   ]
  },
  {
   "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.nba as nba\n",
    "\n",
    "import sdvplot\n",
    "\n",
    "SEASON = 2026  # the 2025-26 season: NBA seasons are named by the year they end\n",
    "LABEL = f\"{SEASON - 1}-{SEASON % 100:02d}\"\n",
    "SOURCE = \"Data: hoopR (ESPN) via sportsdataverse-py\""
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2",
   "metadata": {},
   "source": [
    "The team box score has one row per team per game. `season_type` 2 is the regular season (5 is the play-in, 3 the\n",
    "playoffs). ESPN files the All-Star Game as a regular-season game too, so `resolve` warns about its three teams; keeping\n",
    "only the rows that resolve drops it."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3",
   "metadata": {},
   "outputs": [],
   "source": [
    "box = nba.load_nba_team_boxscore(seasons=[SEASON]).filter(pl.col(\"season_type\") == 2)\n",
    "\n",
    "with warnings.catch_warnings(record=True) as caught:\n",
    "    warnings.simplefilter(\"always\")\n",
    "    team_ids = sdvplot.resolve(box[\"team_abbreviation\"].to_list(), \"nba\")\n",
    "print(caught[0].message)\n",
    "\n",
    "box = box.with_columns(team=pl.Series(team_ids, dtype=pl.String)).filter(pl.col(\"team\").is_not_null())\n",
    "teams = sdvplot.teams(\"nba\").select(\"team_id\", \"conference\")\n",
    "box.select(\n",
    "    \"game_date\", \"team_abbreviation\", \"team\", \"team_score\", \"opponent_team_abbreviation\", \"opponent_team_score\"\n",
    ").head()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4",
   "metadata": {},
   "source": [
    "## 1. Offense vs defense, with logos\n",
    "\n",
    "Points scored and allowed per 100 possessions put every team on one chart. Possessions are estimated from the box\n",
    "score (FGA - OREB + TOV + 0.44 x FTA), averaged with the opponent's."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5",
   "metadata": {},
   "outputs": [],
   "source": [
    "poss = (\n",
    "    pl.col(\"field_goals_attempted\")\n",
    "    - pl.col(\"offensive_rebounds\")\n",
    "    + pl.col(\"total_turnovers\")\n",
    "    + 0.44 * pl.col(\"free_throws_attempted\")\n",
    ")\n",
    "games = box.with_columns(poss=poss)\n",
    "opponent = games.select(\"game_id\", pl.col(\"team_id\").alias(\"opponent_team_id\"), pl.col(\"poss\").alias(\"opp_poss\"))\n",
    "assert games.schema[\"opponent_team_id\"] == opponent.schema[\"opponent_team_id\"]\n",
    "games = games.join(opponent, on=[\"game_id\", \"opponent_team_id\"]).with_columns(\n",
    "    game_poss=(pl.col(\"poss\") + pl.col(\"opp_poss\")) / 2\n",
    ")\n",
    "\n",
    "ratings = (\n",
    "    games.group_by(\"team\", \"team_abbreviation\", maintain_order=True)\n",
    "    .agg(\n",
    "        pace=pl.col(\"game_poss\").mean(),\n",
    "        ortg=100 * pl.col(\"team_score\").sum() / pl.col(\"game_poss\").sum(),\n",
    "        drtg=100 * pl.col(\"opponent_team_score\").sum() / pl.col(\"game_poss\").sum(),\n",
    "        fg3a_rate=pl.col(\"three_point_field_goals_attempted\").sum() / pl.col(\"field_goals_attempted\").sum(),\n",
    "        fg3_pct=pl.col(\"three_point_field_goals_made\").sum() / pl.col(\"three_point_field_goals_attempted\").sum(),\n",
    "    )\n",
    "    .with_columns(net=pl.col(\"ortg\") - pl.col(\"drtg\"))\n",
    "    .join(teams, left_on=\"team\", right_on=\"team_id\")\n",
    "    .sort(\"net\", descending=True)\n",
    ")\n",
    "ratings.head()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Scatter of NBA offensive vs defensive rating with each team's logo as its marker",
     "title": "NBA offense vs defense with logos"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "fig, ax = plt.subplots(figsize=(9, 6))\n",
    "pad = 1.2\n",
    "ax.set_xlim(ratings[\"ortg\"].min() - pad, ratings[\"ortg\"].max() + pad)\n",
    "ax.set_ylim(ratings[\"drtg\"].max() + pad, ratings[\"drtg\"].min() - 2 * pad)  # inverted: better defense is up\n",
    "ax.axvline(ratings[\"ortg\"].mean(), color=\"grey\", linestyle=\"--\", linewidth=0.8)\n",
    "ax.axhline(ratings[\"drtg\"].mean(), color=\"grey\", linestyle=\"--\", linewidth=0.8)\n",
    "sdvplot.add_logos(ax, ratings[\"ortg\"], ratings[\"drtg\"], ratings[\"team\"], league=\"nba\", season=SEASON, height=0.08)\n",
    "\n",
    "corners = {\n",
    "    (0.98, 0.97): \"Good offense, good defense\",\n",
    "    (0.02, 0.97): \"Defense first\",\n",
    "    (0.98, 0.03): \"Offense first\",\n",
    "    (0.02, 0.03): \"Rebuilding\",\n",
    "}\n",
    "for (x, y), text in corners.items():\n",
    "    ax.text(\n",
    "        x,\n",
    "        y,\n",
    "        text,\n",
    "        transform=ax.transAxes,\n",
    "        ha=\"right\" if x > 0.5 else \"left\",\n",
    "        va=\"top\" if y > 0.5 else \"bottom\",\n",
    "        color=\"grey\",\n",
    "        fontstyle=\"italic\",\n",
    "    )\n",
    "ax.set_xlabel(\"Offensive rating (points per 100 possessions)\")\n",
    "ax.set_ylabel(\"Defensive rating (points allowed per 100)\")\n",
    "ax.set_title(f\"NBA offense vs defense, {LABEL} regular season\", loc=\"left\", fontweight=\"bold\")\n",
    "fig.text(0.99, 0.01, SOURCE, ha=\"right\", va=\"bottom\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7",
   "metadata": {},
   "source": [
    "## 2. Net rating, ranked, with logos on the axis\n",
    "\n",
    "`axis_logos` swaps an axis' tick labels for logos. It reads the labels when called, so draw the bars first; the\n",
    "labels can be the data's own ESPN abbreviations (`GS`, `NO`, `UTAH`)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8",
   "metadata": {},
   "outputs": [],
   "source": [
    "fig, ax = plt.subplots(figsize=(10, 5))\n",
    "ax.bar(ratings[\"team_abbreviation\"], ratings[\"net\"], color=sdvplot.team_colors(ratings[\"team\"], \"nba\", season=SEASON))\n",
    "ax.axhline(0, color=\"black\", linewidth=0.8)\n",
    "ax.margins(x=0.01)\n",
    "ax.set_ylabel(\"Net rating (per 100 possessions)\")\n",
    "ax.set_title(f\"NBA net rating, {LABEL} regular season\", loc=\"left\", fontweight=\"bold\")\n",
    "ax.spines[[\"top\", \"right\"]].set_visible(False)\n",
    "sdvplot.axis_logos(ax, \"x\", league=\"nba\", season=SEASON, height=0.07)\n",
    "fig.text(0.99, 0.01, SOURCE, ha=\"right\", va=\"bottom\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9",
   "metadata": {},
   "source": [
    "## 3. A season-long bump chart\n",
    "\n",
    "Rank each team inside its conference by win percentage at the end of every week, then draw one line per team in its\n",
    "color with its logo at the finish. Ties are broken arbitrarily here, not by the NBA's tiebreakers."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "10",
   "metadata": {},
   "outputs": [],
   "source": [
    "weekly = (\n",
    "    box.join(teams, left_on=\"team\", right_on=\"team_id\")\n",
    "    .with_columns(week=pl.col(\"game_date\").dt.truncate(\"1w\"))\n",
    "    .group_by(\"team\", \"conference\", \"week\", maintain_order=True)\n",
    "    .agg(wins=pl.col(\"team_winner\").sum(), games=pl.len())\n",
    ")\n",
    "grid = (\n",
    "    weekly.select(\"team\", \"conference\")\n",
    "    .unique(maintain_order=True)\n",
    "    .join(weekly.select(\"week\").unique(maintain_order=True), how=\"cross\")\n",
    ")\n",
    "bump = (\n",
    "    grid.join(weekly, on=[\"team\", \"conference\", \"week\"], how=\"left\")\n",
    "    .fill_null(0)\n",
    "    .sort(\"week\", \"team\")  # ties in a week's win share rank in this order\n",
    "    .with_columns(pl.col(\"wins\", \"games\").cum_sum().over(\"team\"), week_no=pl.col(\"week\").rank(\"dense\"))\n",
    "    .filter(pl.col(\"week_no\") >= 3)  # skip the first two weeks, when records are a game or two\n",
    "    .with_columns(rank=(pl.col(\"wins\") / pl.col(\"games\")).rank(\"ordinal\", descending=True).over(\"conference\", \"week\"))\n",
    ")\n",
    "\n",
    "west = bump.filter(pl.col(\"conference\") == \"Western Conference\")\n",
    "fig, ax = plt.subplots(figsize=(10, 6))\n",
    "for (team,), line in west.sort(\"week\").group_by(\"team\", maintain_order=True):\n",
    "    ax.plot(line[\"week_no\"], line[\"rank\"], color=sdvplot.team_colors([team], \"nba\")[0], linewidth=2.5, alpha=0.85)\n",
    "final = west.filter(pl.col(\"week_no\") == pl.col(\"week_no\").max())\n",
    "ax.set_xlim(west[\"week_no\"].min() - 0.5, west[\"week_no\"].max() + 1.5)\n",
    "ax.set_ylim(15.8, 0.2)\n",
    "ax.set_yticks(range(1, 16))\n",
    "sdvplot.add_logos(ax, final[\"week_no\"] + 0.9, final[\"rank\"], final[\"team\"], league=\"nba\", season=SEASON, height=0.055)\n",
    "ax.set_xlabel(\"Week of the season\")\n",
    "ax.set_ylabel(\"Western Conference rank\")\n",
    "ax.set_title(f\"The race in the West, {LABEL}\", loc=\"left\", fontweight=\"bold\")\n",
    "ax.spines[[\"top\", \"right\"]].set_visible(False)\n",
    "fig.text(0.99, 0.01, SOURCE, ha=\"right\", va=\"bottom\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "11",
   "metadata": {},
   "source": [
    "## 4. A scoring leaderboard with headshots\n",
    "\n",
    "The player box score carries ESPN athlete ids, which is all `add_headshots` needs. Only games in `box` count, so the\n",
    "All-Star Game stays out."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "12",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Horizontal bars of points per game for the top ten scorers, each with a team logo and headshot",
     "title": "NBA scoring leaders with headshots"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "players = nba.load_nba_player_boxscore(seasons=[SEASON]).join(box.select(\"game_id\").unique(), on=\"game_id\", how=\"semi\")\n",
    "leaders = (\n",
    "    players.filter(~pl.col(\"did_not_play\"))\n",
    "    .group_by(\"athlete_id\", \"athlete_display_name\", maintain_order=True)\n",
    "    .agg(\n",
    "        games=pl.len(),\n",
    "        ppg=pl.col(\"points\").mean(),\n",
    "        team=pl.col(\"team_abbreviation\").sort_by(\"game_date\").last(),\n",
    "    )\n",
    "    .filter(pl.col(\"games\") >= 50)\n",
    "    .sort(\"ppg\", descending=True)\n",
    "    .head(10)\n",
    "    .reverse()  # barh draws bottom-up: the leader goes on top\n",
    ")\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(9, 6))\n",
    "y = list(range(len(leaders)))\n",
    "ax.barh(y, leaders[\"ppg\"], color=sdvplot.team_colors(leaders[\"team\"], \"nba\", season=SEASON), height=0.7)\n",
    "ax.set_yticks(y, leaders[\"athlete_display_name\"])\n",
    "ax.set_xlim(0, leaders[\"ppg\"].max() + 9)\n",
    "sdvplot.add_logos(ax, leaders[\"ppg\"] + 1.6, y, leaders[\"team\"], league=\"nba\", season=SEASON, height=0.075)\n",
    "sdvplot.add_headshots(ax, leaders[\"ppg\"] + 4.8, y, leaders[\"athlete_id\"], league=\"nba\", height=0.085)\n",
    "for yi, ppg in zip(y, leaders[\"ppg\"], strict=True):\n",
    "    ax.text(ppg + 7, yi, f\"{ppg:.1f}\", va=\"center\", fontweight=\"bold\")\n",
    "ax.set_xlabel(\"Points per game\")\n",
    "ax.set_title(f\"NBA scoring leaders, {LABEL} (50+ games)\", loc=\"left\", fontweight=\"bold\")\n",
    "ax.spines[[\"top\", \"right\"]].set_visible(False)\n",
    "fig.text(0.99, 0.01, SOURCE, ha=\"right\", va=\"bottom\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "13",
   "metadata": {},
   "source": [
    "## 5. A shot chart on a team-colored court\n",
    "\n",
    "`load_nba_shots` holds ESPN's shot locations, which sportsdataverse-py already converts to feet on a center-court\n",
    "frame: the same frame as sportypy's court, so they plot as they are. (`sdvplot.court_coords` is only for the\n",
    "stats.nba.com legacy frame, in tenths of a foot around the hoop.) Shots at the right basket are rotated onto the left\n",
    "one so a half court holds them all."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "14",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Made and missed field goal attempts of the NBA scoring leader on a half court painted in his team colors",
     "title": "NBA shot chart on a team-colored court"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "shots = nba.load_nba_shots(seasons=[SEASON]).join(box.select(\"game_id\").unique(), on=\"game_id\", how=\"semi\")\n",
    "star = leaders.row(-1, named=True)  # the scoring leader\n",
    "right = pl.col(\"coordinate_x\") > 0\n",
    "player = shots.filter(\n",
    "    (pl.col(\"athlete_id_1\") == star[\"athlete_id\"]) & ~pl.col(\"type_text\").str.contains(\"Free Throw\")\n",
    ").with_columns(\n",
    "    x=pl.when(right).then(-pl.col(\"coordinate_x\")).otherwise(pl.col(\"coordinate_x\")),\n",
    "    y=pl.when(right).then(-pl.col(\"coordinate_y\")).otherwise(pl.col(\"coordinate_y\")),\n",
    ")\n",
    "made = player.filter(pl.col(\"scoring_play\"))\n",
    "missed = player.filter(~pl.col(\"scoring_play\"))\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(7, 7))\n",
    "sdvplot.surface(\"nba\", star[\"team\"], season=SEASON, display_range=\"defense\", ax=ax)\n",
    "ax.scatter(\n",
    "    missed[\"x\"],\n",
    "    missed[\"y\"],\n",
    "    marker=\"x\",\n",
    "    color=\"#3d3d3d\",\n",
    "    s=14,\n",
    "    linewidths=0.8,\n",
    "    alpha=0.6,\n",
    "    zorder=20,\n",
    "    label=f\"Missed ({missed.height})\",\n",
    ")\n",
    "ax.scatter(\n",
    "    made[\"x\"],\n",
    "    made[\"y\"],\n",
    "    color=sdvplot.team_colors([star[\"team\"]], \"nba\", which=\"secondary\")[0],\n",
    "    edgecolors=\"black\",\n",
    "    linewidths=0.4,\n",
    "    s=18,\n",
    "    zorder=21,\n",
    "    label=f\"Made ({made.height})\",\n",
    ")\n",
    "ax.legend(loc=\"upper center\", bbox_to_anchor=(0.5, 0.02), ncols=2, frameon=False)\n",
    "ax.set_title(\n",
    "    f\"{star['athlete_display_name']}: every field goal attempt, {LABEL} regular season\\n\"\n",
    "    f\"{made.height / player.height:.1%} from the field\",\n",
    "    loc=\"left\",\n",
    "    fontweight=\"bold\",\n",
    ")\n",
    "fig.text(0.99, 0.01, SOURCE, ha=\"right\", va=\"bottom\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "15",
   "metadata": {},
   "source": [
    "## 6. A team palette for seaborn\n",
    "\n",
    "`palette` maps the data's own team values to colors, so seaborn can color each box by team. Here: every game's\n",
    "points scored for the Eastern Conference, highest median first."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "16",
   "metadata": {},
   "outputs": [],
   "source": [
    "import seaborn as sns\n",
    "\n",
    "east = box.join(teams, left_on=\"team\", right_on=\"team_id\").filter(pl.col(\"conference\") == \"Eastern Conference\")\n",
    "order = (\n",
    "    east.group_by(\"team_abbreviation\", maintain_order=True)\n",
    "    .agg(pl.col(\"team_score\").median())\n",
    "    .sort(\"team_score\", descending=True)\n",
    ")[\"team_abbreviation\"].to_list()\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(10, 5))\n",
    "sns.boxplot(\n",
    "    east.to_pandas(),\n",
    "    x=\"team_abbreviation\",\n",
    "    y=\"team_score\",\n",
    "    order=order,\n",
    "    hue=\"team_abbreviation\",\n",
    "    palette=sdvplot.palette(\"nba\", teams=east[\"team_abbreviation\"], season=SEASON),\n",
    "    legend=False,\n",
    "    medianprops={\"color\": \"white\", \"linewidth\": 2},\n",
    "    flierprops={\"markersize\": 3},\n",
    "    ax=ax,\n",
    ")\n",
    "ax.set_xlabel(\"\")\n",
    "ax.set_ylabel(\"Points scored in a game\")\n",
    "ax.set_title(f\"Eastern Conference scoring, game by game, {LABEL}\", loc=\"left\", fontweight=\"bold\")\n",
    "sdvplot.axis_logos(ax, \"x\", league=\"nba\", season=SEASON, height=0.08)\n",
    "fig.text(0.99, 0.01, SOURCE, ha=\"right\", va=\"bottom\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "17",
   "metadata": {},
   "source": [
    "## 7. plotnine: logos faceted by conference\n",
    "\n",
    "`geom_sdv_logos` is a plotnine layer, so it facets like any other geom, and `geom_mean_lines` draws each panel's own\n",
    "averages. How often teams shoot threes against how well they make them, East vs West:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "18",
   "metadata": {},
   "outputs": [],
   "source": [
    "from plotnine import aes, facet_wrap, ggplot, labs, scale_x_continuous, scale_y_continuous, theme, theme_bw\n",
    "\n",
    "from sdvplot.plotnine import geom_mean_lines, geom_sdv_logos\n",
    "\n",
    "pct = lambda breaks: [f\"{b:.0%}\" for b in breaks]  # noqa: E731\n",
    "(\n",
    "    ggplot(ratings.to_pandas(), aes(\"fg3a_rate\", \"fg3_pct\", team=\"team\"))\n",
    "    + geom_mean_lines(aes(x0=\"fg3a_rate\", y0=\"fg3_pct\"), color=\"grey\")\n",
    "    + geom_sdv_logos(league=\"nba\", season=SEASON, height=0.1)\n",
    "    + facet_wrap(\"conference\")\n",
    "    + scale_x_continuous(labels=pct)\n",
    "    + scale_y_continuous(labels=pct)\n",
    "    + labs(\n",
    "        x=\"Share of field goal attempts from three\",\n",
    "        y=\"Three-point percentage\",\n",
    "        title=f\"Three-point volume vs accuracy, {LABEL}\",\n",
    "        caption=SOURCE,\n",
    "    )\n",
    "    + theme_bw()\n",
    "    + theme(figure_size=(10, 5))\n",
    ")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "19",
   "metadata": {},
   "source": [
    "## 8. A standings table with logos\n",
    "\n",
    "ESPN's standings come long (one row per team and stat); pivot them wide, then let `gt_sdv_logos` turn the team column\n",
    "into logos and `gt_cutline` mark the playoff and play-in lines. The Western Conference:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "20",
   "metadata": {},
   "outputs": [],
   "source": [
    "from great_tables import GT\n",
    "\n",
    "from sdvplot.great_tables import gt_cutline, gt_sdv_logos, gt_theme_athletic\n",
    "\n",
    "standings = nba.load_nba_standings(seasons=[SEASON])\n",
    "west_table = (\n",
    "    standings.filter(pl.col(\"group_name\") == \"Western Conference\")\n",
    "    .pivot(on=\"stat_name\", index=[\"team_abbreviation\", \"team_display_name\"], values=\"display_value\")\n",
    "    .with_columns(seed=pl.col(\"playoffSeed\").cast(pl.Int32), logo=pl.col(\"team_abbreviation\"))\n",
    "    .sort(\"seed\")\n",
    "    .select(\n",
    "        \"seed\",\n",
    "        \"logo\",\n",
    "        \"team_display_name\",\n",
    "        \"wins\",\n",
    "        \"losses\",\n",
    "        \"winPercent\",\n",
    "        \"gamesBehind\",\n",
    "        \"Home\",\n",
    "        \"Road\",\n",
    "        \"Last Ten Games\",\n",
    "        \"streak\",\n",
    "        \"differential\",\n",
    "    )\n",
    ")\n",
    "table = (\n",
    "    GT(west_table)\n",
    "    .tab_header(\n",
    "        title=f\"Western Conference standings, {LABEL}\", subtitle=\"Seeds 1-6 make the playoffs; 7-10 the play-in\"\n",
    "    )\n",
    "    .cols_label(\n",
    "        seed=\"\",\n",
    "        logo=\"\",\n",
    "        team_display_name=\"Team\",\n",
    "        wins=\"W\",\n",
    "        losses=\"L\",\n",
    "        winPercent=\"Pct\",\n",
    "        gamesBehind=\"GB\",\n",
    "        **{\"Last Ten Games\": \"L10\"},\n",
    "        streak=\"Strk\",\n",
    "        differential=\"Diff\",\n",
    "    )\n",
    "    .tab_source_note(SOURCE)\n",
    ")\n",
    "table = gt_sdv_logos(table, \"logo\", league=\"nba\", season=SEASON, height=26)\n",
    "table = gt_theme_athletic(table).cols_align(\"left\", columns=\"team_display_name\")  # theme first: it sets alignment\n",
    "gt_cutline(table, after=[6, 10], label=[\"Playoffs\", \"Play-in\"], label_position=\"above\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "21",
   "metadata": {},
   "source": [
    "## 9. An interactive Plotly chart\n",
    "\n",
    "The same logos work on a Plotly figure: hover a logo for the numbers, zoom and the logos scale with the data.\n",
    "Pace against net rating:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "22",
   "metadata": {},
   "outputs": [],
   "source": [
    "import plotly.graph_objects as go\n",
    "\n",
    "fig = go.Figure(\n",
    "    go.Scatter(\n",
    "        x=ratings[\"pace\"].to_list(),\n",
    "        y=ratings[\"net\"].to_list(),\n",
    "        mode=\"markers\",\n",
    "        marker={\"size\": 30, \"opacity\": 0},\n",
    "        text=ratings[\"team_abbreviation\"].to_list(),\n",
    "        hovertemplate=\"%{text}<br>Pace %{x:.1f}<br>Net rating %{y:+.1f}<extra></extra>\",\n",
    "    )\n",
    ")\n",
    "sdvplot.add_logos(fig, ratings[\"pace\"], ratings[\"net\"], ratings[\"team\"], league=\"nba\", season=SEASON, height=0.08)\n",
    "fig.add_hline(y=0, line_dash=\"dot\", line_color=\"grey\")\n",
    "fig.update_layout(\n",
    "    title=f\"Pace vs net rating, {LABEL} regular season<br><sup>{SOURCE}</sup>\",\n",
    "    xaxis_title=\"Pace (possessions per game)\",\n",
    "    yaxis_title=\"Net rating (per 100 possessions)\",\n",
    "    template=\"plotly_white\",\n",
    "    width=850,\n",
    "    height=550,\n",
    ")\n",
    "fig"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "23",
   "metadata": {},
   "source": [
    "## 10. The G League\n",
    "\n",
    "sportsdataverse-py has no G League loader, so read ESPN's public standings endpoint for the G League (slug\n",
    "`nba-development`) with `requests` and keep the two numbers needed: points scored and allowed per game. sdvplot\n",
    "knows the G League as `nbagl`; its teams resolve by ESPN abbreviation like any other league."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "24",
   "metadata": {},
   "outputs": [],
   "source": [
    "import requests\n",
    "\n",
    "url = \"https://site.api.espn.com/apis/v2/sports/basketball/nba-development/standings\"\n",
    "payload = requests.get(url, params={\"season\": SEASON}, timeout=30).json()\n",
    "per_game = {\"avgPointsFor\": \"scored\", \"avgPointsAgainst\": \"allowed\"}\n",
    "gleague = pl.DataFrame(\n",
    "    [\n",
    "        {\"team\": entry[\"team\"][\"abbreviation\"]}\n",
    "        | {per_game[s[\"name\"]]: s[\"value\"] for s in entry[\"stats\"] if s[\"name\"] in per_game}\n",
    "        for conference in payload[\"children\"]  # one child per conference\n",
    "        for entry in conference[\"standings\"][\"entries\"]\n",
    "    ]\n",
    ")\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(9, 6))\n",
    "lo = min(gleague[\"scored\"].min(), gleague[\"allowed\"].min()) - 1\n",
    "hi = max(gleague[\"scored\"].max(), gleague[\"allowed\"].max()) + 1\n",
    "ax.set_xlim(lo, hi)\n",
    "ax.set_ylim(hi, lo)  # inverted: fewer points allowed is up\n",
    "for margin in (-6, -3, 0, 3, 6):  # lines of equal scoring margin: allowed = scored - margin\n",
    "    ax.plot([lo, hi], [lo - margin, hi - margin], color=\"grey\", linewidth=0.6, linestyle=\":\")\n",
    "    exit_point = (hi, hi - margin) if margin > 0 else (hi + margin, hi)  # where the line leaves the plot\n",
    "    ax.annotate(\n",
    "        f\"{margin:+d}\" if margin else \"0\",\n",
    "        exit_point,\n",
    "        xytext=(-3, 3),\n",
    "        textcoords=\"offset points\",\n",
    "        ha=\"right\",\n",
    "        va=\"bottom\",\n",
    "        color=\"grey\",\n",
    "        fontsize=8,\n",
    "    )\n",
    "sdvplot.add_logos(ax, gleague[\"scored\"], gleague[\"allowed\"], gleague[\"team\"], league=\"nbagl\", height=0.08)\n",
    "ax.set_xlabel(\"Points scored per game\")\n",
    "ax.set_ylabel(\"Points allowed per game\")\n",
    "ax.set_title(f\"NBA G League scoring margin, {LABEL} regular season\", loc=\"left\", fontweight=\"bold\")\n",
    "fig.text(0.99, 0.01, \"Data: ESPN site API\", ha=\"right\", va=\"bottom\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "25",
   "metadata": {},
   "source": [
    "Dotted lines mark equal scoring margins (+6 to -6 per game). Not every G League team has official colors in the index:\n",
    "check `color_source` before using a color as the team's own."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "26",
   "metadata": {},
   "outputs": [],
   "source": [
    "sdvplot.teams(\"nbagl\").filter(pl.col(\"color_source\") == \"fallback\").select(\n",
    "    \"abbr\", \"name\", \"color_primary\", \"color_source\"\n",
    ")"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "sdvplot": {
   "description": "Ten NBA and G League examples: rating scatters, logo axes, a bump chart, headshots, a shot chart, a standings table and an interactive Plotly chart.",
   "label": "NBA",
   "position": 20
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
