{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "0",
   "metadata": {},
   "source": [
    "# NHL\n",
    "\n",
    "Ten charts and tables from the 2025-26 NHL season: standings, expected goals, a shot map on a rink, scoring leaders\n",
    "with headshots, the Coyotes-to-Utah relocation, a division points race and a playoff tier list. The data is the\n",
    "fastRhockey release (play-by-play with expected goals, box scores) and the NHL's own api-web.nhle.com feed, both read\n",
    "through [sportsdataverse-py](https://py.sportsdataverse.org/)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1",
   "metadata": {},
   "outputs": [],
   "source": [
    "import matplotlib.pyplot as plt\n",
    "import polars as pl\n",
    "import sportsdataverse.nhl as nhl\n",
    "\n",
    "import sdvplot\n",
    "\n",
    "SEASON = 2026  # the 2025-26 season, named by the year it ends"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2",
   "metadata": {},
   "source": [
    "Load the season once. The play-by-play release has every event with rink coordinates and an expected-goals value\n",
    "(`xg`); the team box scores have one row per team and game; `nhl_standings` reads the final regular-season table from\n",
    "api-web.nhle.com."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3",
   "metadata": {},
   "outputs": [],
   "source": [
    "pbp = nhl.load_nhl_pbp_lite(seasons=[SEASON]).select(\n",
    "    \"game_id\",\n",
    "    \"season_type\",\n",
    "    \"period\",\n",
    "    \"event_type\",\n",
    "    \"event_team_abbr\",\n",
    "    \"event_team_type\",\n",
    "    \"strength_state\",\n",
    "    \"x_fixed\",\n",
    "    \"y_fixed\",\n",
    "    \"xg\",\n",
    ")\n",
    "team_box = nhl.load_nhl_team_box(seasons=[SEASON]).select(\n",
    "    \"game_id\",\n",
    "    \"game_date\",\n",
    "    \"team_abbrev\",\n",
    "    \"goals\",\n",
    "    \"goals_against\",\n",
    "    (pl.col(\"game_id\") // 10_000 % 100).alias(\"game_type\"),  # 2025020001: regular season (2); 2025030416: playoffs (3)\n",
    ")\n",
    "regular = team_box.filter(pl.col(\"game_type\") == 2)\n",
    "standings = nhl.nhl_standings(\"2026-04-16\")  # the last day of the regular season\n",
    "pbp.height, team_box.height, standings.height"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4",
   "metadata": {},
   "source": [
    "## 1. Final standings with logos\n",
    "\n",
    "A standings table grouped by division. `gt_sdv_logos` turns the NHL's own team codes (`COL`, `UTA`, ...) into logos;\n",
    "the codes resolve through the index, so nothing is mapped by hand."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5",
   "metadata": {},
   "outputs": [],
   "source": [
    "from great_tables import GT\n",
    "\n",
    "from sdvplot.great_tables import gt_sdv_logos, gt_theme_athletic\n",
    "\n",
    "table = standings.sort(\"division_name\", \"division_sequence\").select(\n",
    "    pl.col(\"division_name\").alias(\"division\"),\n",
    "    pl.col(\"team_abbrev_default\").alias(\"logo\"),\n",
    "    pl.col(\"team_name_default\").alias(\"team\"),\n",
    "    pl.col(\"games_played\").alias(\"gp\"),\n",
    "    pl.col(\"wins\").alias(\"w\"),\n",
    "    pl.col(\"losses\").alias(\"l\"),\n",
    "    pl.col(\"ot_losses\").alias(\"otl\"),\n",
    "    pl.col(\"points\").alias(\"pts\"),\n",
    "    pl.col(\"point_pctg\").alias(\"pts_pct\"),\n",
    "    pl.col(\"goal_differential\").alias(\"diff\"),\n",
    ")\n",
    "gt = (\n",
    "    GT(table, groupname_col=\"division\")\n",
    "    .tab_header(\"NHL standings, 2025-26\", \"Final regular season, grouped by division\")\n",
    "    .fmt_number(\"pts_pct\", decimals=3)\n",
    "    .cols_align(\"left\", \"team\")\n",
    "    .cols_label(logo=\"\", team=\"Team\", gp=\"GP\", w=\"W\", l=\"L\", otl=\"OTL\", pts=\"PTS\", pts_pct=\"PTS%\", diff=\"DIFF\")\n",
    "    .tab_source_note(\"Data: api-web.nhle.com via sportsdataverse-py\")\n",
    ")\n",
    "gt_theme_athletic(gt_sdv_logos(gt, \"logo\", league=\"nhl\", height=22))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6",
   "metadata": {},
   "source": [
    "## 2. Goals for and against\n",
    "\n",
    "Goals scored and allowed per game, one logo per team. The y axis is reversed so the good defensive teams sit at the\n",
    "top: the top-right corner is where you want to be."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7",
   "metadata": {},
   "outputs": [],
   "source": [
    "gpg = standings.select(\n",
    "    pl.col(\"team_abbrev_default\").alias(\"team\"),\n",
    "    (pl.col(\"goal_for\") / pl.col(\"games_played\")).alias(\"gf\"),\n",
    "    (pl.col(\"goal_against\") / pl.col(\"games_played\")).alias(\"ga\"),\n",
    ")\n",
    "fig, ax = plt.subplots(figsize=(8, 6))\n",
    "ax.scatter(gpg[\"gf\"], gpg[\"ga\"], alpha=0)  # sets the limits; the logos are the points\n",
    "ax.axvline(gpg[\"gf\"].mean(), color=\"grey\", lw=0.8, ls=\"--\")\n",
    "ax.axhline(gpg[\"ga\"].mean(), color=\"grey\", lw=0.8, ls=\"--\")\n",
    "ax.invert_yaxis()\n",
    "ax.margins(0.08)\n",
    "sdvplot.add_logos(ax, gpg[\"gf\"], gpg[\"ga\"], gpg[\"team\"], league=\"nhl\", season=SEASON, height=0.07)\n",
    "ax.set(xlabel=\"Goals for per game\", ylabel=\"Goals against per game (reversed)\")\n",
    "ax.set_title(\"Goals for and against per game, 2025-26\", loc=\"left\", fontweight=\"bold\")\n",
    "fig.text(0.99, 0.01, \"Data: api-web.nhle.com via sportsdataverse-py\", ha=\"right\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8",
   "metadata": {},
   "source": [
    "Colorado led both ways, scoring 3.68 and allowing 2.48 a game on the way to 121 points; Vancouver allowed 3.85 a game\n",
    "and finished last with 58."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9",
   "metadata": {},
   "source": [
    "## 3. Five-on-five expected goals with plotnine\n",
    "\n",
    "Expected goals (xG) weigh every unblocked shot by its chance of scoring. Summed at five-on-five for and against each\n",
    "team, they show who drives play, with less luck than goals. `geom_sdv_logos` draws the logos and `geom_mean_lines`\n",
    "the league averages."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "10",
   "metadata": {},
   "outputs": [],
   "source": [
    "from plotnine import aes, element_text, ggplot, labs, scale_y_reverse, theme, theme_minimal\n",
    "\n",
    "from sdvplot.plotnine import geom_mean_lines, geom_sdv_logos\n",
    "\n",
    "shots = pbp.filter((pl.col(\"season_type\") == \"R\") & (pl.col(\"strength_state\") == \"5v5\") & pl.col(\"xg\").is_not_null())\n",
    "games = regular.group_by(\"team_abbrev\", maintain_order=True).agg(pl.len().alias(\"gp\"))\n",
    "xg_for = shots.group_by(pl.col(\"event_team_abbr\").alias(\"team_abbrev\"), maintain_order=True).agg(\n",
    "    pl.col(\"xg\").sum().alias(\"xgf\")\n",
    ")\n",
    "# a shot against a team is a shot by its opponent in the same game\n",
    "opp = (\n",
    "    regular.select(\"game_id\", \"team_abbrev\")\n",
    "    .join(regular.select(\"game_id\", pl.col(\"team_abbrev\").alias(\"opponent\")), on=\"game_id\")\n",
    "    .filter(pl.col(\"team_abbrev\") != pl.col(\"opponent\"))\n",
    ")\n",
    "shots = shots.with_columns(pl.col(\"game_id\").cast(pl.Int64))  # Int32 in the play-by-play, Int64 in the box scores\n",
    "assert shots.schema[\"game_id\"] == opp.schema[\"game_id\"]\n",
    "xg_against = (\n",
    "    shots.join(opp, left_on=[\"game_id\", \"event_team_abbr\"], right_on=[\"game_id\", \"opponent\"])\n",
    "    .group_by(\"team_abbrev\", maintain_order=True)\n",
    "    .agg(pl.col(\"xg\").sum().alias(\"xga\"))\n",
    ")\n",
    "xg = (\n",
    "    xg_for.join(xg_against, on=\"team_abbrev\")\n",
    "    .join(games, on=\"team_abbrev\")\n",
    "    .with_columns((pl.col(\"xgf\") / pl.col(\"gp\")).alias(\"xgf_pg\"), (pl.col(\"xga\") / pl.col(\"gp\")).alias(\"xga_pg\"))\n",
    ")\n",
    "(\n",
    "    ggplot(xg.to_pandas(), aes(\"xgf_pg\", \"xga_pg\", x0=\"xgf_pg\", y0=\"xga_pg\", team=\"team_abbrev\"))\n",
    "    + geom_mean_lines(color=\"grey\")\n",
    "    + geom_sdv_logos(league=\"nhl\", season=SEASON, height=0.075)\n",
    "    + scale_y_reverse()\n",
    "    + labs(\n",
    "        x=\"5v5 xG for per game\",\n",
    "        y=\"5v5 xG against per game (reversed)\",\n",
    "        title=\"Who drives play at five-on-five, 2025-26\",\n",
    "        caption=\"Data: fastRhockey play-by-play via sportsdataverse-py\",\n",
    "    )\n",
    "    + theme_minimal()\n",
    "    + theme(figure_size=(8, 6), plot_title=element_text(weight=\"bold\"))\n",
    ")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "11",
   "metadata": {},
   "source": [
    "## 4. A shot map on the rink\n",
    "\n",
    "`surface(\"nhl\", team)` draws a regulation rink with sportypy, its center line, faceoff circle and boards in the\n",
    "team's colors. The release's `x_fixed` puts the home team shooting right and the away team left; flipping the away\n",
    "shots (both x and y) puts every shot in one attacking end."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "12",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Carolina's 2025-26 unblocked shots and goals on a half rink in team colors",
     "title": "NHL shot map on a rink"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "from sdvplot.matplotlib import title_image\n",
    "\n",
    "team = \"CAR\"\n",
    "mine = (\n",
    "    pbp.filter(\n",
    "        (pl.col(\"event_team_abbr\") == team)\n",
    "        & pl.col(\"event_type\").is_in([\"SHOT\", \"MISSED_SHOT\", \"GOAL\"])\n",
    "        & pl.col(\"x_fixed\").is_not_null()\n",
    "    )\n",
    "    .with_columns(flip=pl.when(pl.col(\"event_team_type\") == \"away\").then(-1).otherwise(1))\n",
    "    .with_columns(x=pl.col(\"x_fixed\") * pl.col(\"flip\"), y=pl.col(\"y_fixed\") * pl.col(\"flip\"))\n",
    ")\n",
    "goals, others = mine.filter(pl.col(\"event_type\") == \"GOAL\"), mine.filter(pl.col(\"event_type\") != \"GOAL\")\n",
    "color = sdvplot.team_colors(team, \"nhl\")\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(8, 6))\n",
    "sdvplot.surface(\"nhl\", team, ax=ax, display_range=\"offense\")\n",
    "ax.scatter(\n",
    "    others[\"x\"], others[\"y\"], s=6, color=\"grey\", alpha=0.25, zorder=20, label=f\"Shots and misses ({others.height:,})\"\n",
    ")\n",
    "ax.scatter(\n",
    "    goals[\"x\"], goals[\"y\"], s=18, color=color, edgecolor=\"white\", lw=0.4, zorder=21, label=f\"Goals ({goals.height})\"\n",
    ")\n",
    "ax.legend(loc=\"upper center\", bbox_to_anchor=(0.5, 0.0), ncol=2, fontsize=9, frameon=False)\n",
    "title_image(\n",
    "    ax,\n",
    "    team,\n",
    "    \"Carolina Hurricanes, every unblocked shot of 2025-26\",\n",
    "    league=\"nhl\",\n",
    "    height=26,\n",
    "    loc=\"left\",\n",
    "    fontweight=\"bold\",\n",
    "    pad=10,\n",
    ")\n",
    "fig.text(\n",
    "    0.99,\n",
    "    0.02,\n",
    "    \"Regular season and playoffs. Data: fastRhockey via sportsdataverse-py\",\n",
    "    ha=\"right\",\n",
    "    fontsize=8,\n",
    "    color=\"grey\",\n",
    ")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "13",
   "metadata": {},
   "source": [
    "## 5. Finishing: goals above expected, logos on the axis\n",
    "\n",
    "Goals minus expected goals, all situations. The release's xG model was fit on earlier seasons and expects more goals\n",
    "than 2025-26 produced, so first scale every team's xG by the league's goals-to-xG ratio; what is left is finishing\n",
    "relative to the league. `axis_logos` swaps the team codes on the x axis for their logos."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "14",
   "metadata": {},
   "outputs": [],
   "source": [
    "attempts = pbp.filter(\n",
    "    (pl.col(\"season_type\") == \"R\") & pl.col(\"xg\").is_not_null() & (pl.col(\"period\") <= 4)\n",
    ")  # no shootouts\n",
    "league_goals, league_xg = (attempts[\"event_type\"] == \"GOAL\").sum(), attempts[\"xg\"].sum()\n",
    "print(f\"{league_goals:,} goals on {league_xg:,.0f} expected: the model runs {league_xg / league_goals - 1:.0%} high\")\n",
    "finish = (\n",
    "    attempts.group_by(pl.col(\"event_team_abbr\").alias(\"team\"), maintain_order=True)\n",
    "    .agg((pl.col(\"event_type\") == \"GOAL\").sum().alias(\"goals\"), pl.col(\"xg\").sum().alias(\"xg\"))\n",
    "    .with_columns((pl.col(\"goals\") - pl.col(\"xg\") * league_goals / league_xg).alias(\"gax\"))\n",
    "    .sort(\"gax\", descending=True)\n",
    ")\n",
    "fig, ax = plt.subplots(figsize=(10, 5.5))\n",
    "ax.bar(finish[\"team\"], finish[\"gax\"], color=sdvplot.team_colors(finish[\"team\"], \"nhl\"))\n",
    "ax.axhline(0, color=\"black\", lw=0.8)\n",
    "ax.margins(x=0.01)\n",
    "sdvplot.axis_logos(ax, \"x\", league=\"nhl\", season=SEASON, height=0.05)\n",
    "ax.set_ylabel(\"Goals above expected (league-scaled)\")\n",
    "ax.spines[[\"top\", \"right\"]].set_visible(False)\n",
    "ax.set_title(\"Who finished their chances, 2025-26 regular season\", loc=\"left\", fontweight=\"bold\")\n",
    "fig.text(0.99, 0.01, \"Data: fastRhockey play-by-play via sportsdataverse-py\", ha=\"right\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "15",
   "metadata": {},
   "source": [
    "Boston (+27.5) and Pittsburgh (+27.3) finished best; New Jersey scored 27 fewer goals than its chances were worth."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "16",
   "metadata": {},
   "source": [
    "## 6. Scoring leaders with headshots\n",
    "\n",
    "ESPN's leaders feed carries ESPN athlete ids, which is what `add_headshots` needs for the NHL. Goals and assists stack\n",
    "into points; the player's team logo sits at the end of the bar."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "17",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Top ten NHL scorers of 2025-26 as stacked goal and assist bars with ESPN headshots and team logos",
     "title": "NHL points leaders with headshots"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "raw = nhl.espn_nhl_leaders(season=SEASON, season_type=2, limit=12, return_parsed=False)\n",
    "names = next(c[\"names\"] for c in raw[\"categories\"] if c[\"name\"] == \"offensive\")\n",
    "rows = []\n",
    "for a in raw[\"athletes\"]:\n",
    "    stats = dict(zip(names, next(c[\"values\"] for c in a[\"categories\"] if c[\"name\"] == \"offensive\"), strict=True))\n",
    "    rows.append(\n",
    "        {\n",
    "            \"player_id\": a[\"athlete\"][\"id\"],\n",
    "            \"player\": a[\"athlete\"][\"displayName\"],\n",
    "            \"team\": a[\"athlete\"][\"teamShortName\"],\n",
    "            \"goals\": stats[\"goals\"],\n",
    "            \"assists\": stats[\"assists\"],\n",
    "            \"points\": stats[\"points\"],\n",
    "        }\n",
    "    )\n",
    "leaders = pl.DataFrame(rows).sort(\"points\").tail(10)\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(9, 6))\n",
    "y = range(leaders.height)\n",
    "ax.barh(y, leaders[\"goals\"], color=\"#1f3b73\", label=\"Goals\")\n",
    "ax.barh(y, leaders[\"assists\"], left=leaders[\"goals\"], color=\"#9fb4d8\", label=\"Assists\")\n",
    "ax.set_yticks(list(y), leaders[\"player\"])\n",
    "ax.set_xlim(-22, leaders[\"points\"].max() + 24)\n",
    "ax.tick_params(axis=\"y\", length=0, pad=34)\n",
    "sdvplot.add_headshots(ax, [-11] * leaders.height, list(y), leaders[\"player_id\"], league=\"nhl\", height=0.085)\n",
    "sdvplot.add_logos(ax, (leaders[\"points\"] + 15).to_list(), list(y), leaders[\"team\"], league=\"nhl\", height=0.065)\n",
    "for i, p in enumerate(leaders[\"points\"]):\n",
    "    ax.text(p + 2, i, f\"{p:.0f}\", va=\"center\", fontsize=10, fontweight=\"bold\")\n",
    "ax.spines[[\"top\", \"right\", \"left\"]].set_visible(False)\n",
    "ax.set_xticks([0, 25, 50, 75, 100, 125])\n",
    "ax.legend(loc=\"lower right\", frameon=False)\n",
    "ax.set_title(\"NHL points leaders, 2025-26 regular season\", loc=\"left\", fontweight=\"bold\")\n",
    "fig.text(0.99, 0.01, \"Data: ESPN via sportsdataverse-py\", ha=\"right\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "18",
   "metadata": {},
   "source": [
    "Connor McDavid led with 138 points, 90 of them assists; Nathan MacKinnon scored the most goals of the ten."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "19",
   "metadata": {},
   "source": [
    "## 7. One franchise, many marks: Phoenix, Arizona, Utah\n",
    "\n",
    "The Coyotes moved to Salt Lake City in 2024 (the Utah Hockey Club for a season, the Utah Mammoth since). sdvplot\n",
    "keeps the franchise as one team: the old and new codes resolve to the same `team_id`, and `season` picks the logo of\n",
    "each era."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "20",
   "metadata": {},
   "outputs": [],
   "source": [
    "sdvplot.resolve([\"PHX\", \"ARI\", \"UTA\"], \"nhl\", season=[2000, 2020, 2026])"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "21",
   "metadata": {},
   "outputs": [],
   "source": [
    "eras = {\n",
    "    1997: \"Phoenix Coyotes\",\n",
    "    2004: \"Phoenix Coyotes\",\n",
    "    2015: \"Arizona Coyotes\",\n",
    "    2022: \"Arizona Coyotes\",\n",
    "    2025: \"Utah Hockey Club\",\n",
    "    2026: \"Utah Mammoth\",\n",
    "}\n",
    "fig, axes = plt.subplots(1, len(eras), figsize=(10, 2.6))\n",
    "for ax, (season, name) in zip(axes, eras.items(), strict=True):\n",
    "    ax.imshow(sdvplot.logo_image(\"UTA\", \"nhl\", season=season, size=240))\n",
    "    ax.set_title(f\"{season - 1}-{str(season)[2:]}\\n{name}\", fontsize=9)\n",
    "    ax.axis(\"off\")\n",
    "fig.suptitle(\"The same franchise, by season\", fontweight=\"bold\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "22",
   "metadata": {},
   "source": [
    "## 8. The points race, by division\n",
    "\n",
    "Standings points (two for a win, one for an overtime or shootout loss) game by game, measured against a .500 pace of\n",
    "one point a game, so the lines spread apart instead of all climbing together. The box scores have the score and the\n",
    "play-by-play says which games went past regulation; the totals match the official standings for every team.\n",
    "`scale_color_sdv` colors each line by its team and `geom_sdv_logos` labels the line ends in each facet."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "23",
   "metadata": {},
   "outputs": [],
   "source": [
    "from plotnine import facet_wrap, geom_line, scale_x_continuous\n",
    "\n",
    "from sdvplot.plotnine import scale_color_sdv\n",
    "\n",
    "last_period = (\n",
    "    pbp.group_by(\"game_id\", maintain_order=True)\n",
    "    .agg(pl.col(\"period\").max().alias(\"last_period\"))\n",
    "    .with_columns(pl.col(\"game_id\").cast(pl.Int64))\n",
    ")\n",
    "assert team_box.schema[\"game_id\"] == last_period.schema[\"game_id\"]\n",
    "race = (\n",
    "    regular.join(last_period, on=\"game_id\")\n",
    "    .with_columns(\n",
    "        pts=pl.when(pl.col(\"goals\") > pl.col(\"goals_against\"))\n",
    "        .then(2)\n",
    "        .when(pl.col(\"last_period\") > 3)\n",
    "        .then(1)\n",
    "        .otherwise(0)\n",
    "    )\n",
    "    .sort(\"game_date\", \"game_id\")\n",
    "    .with_columns(\n",
    "        game=pl.col(\"game_id\").cum_count().over(\"team_abbrev\"),\n",
    "        points=pl.col(\"pts\").cum_sum().over(\"team_abbrev\"),\n",
    "    )\n",
    "    .join(\n",
    "        standings.select(\n",
    "            pl.col(\"team_abbrev_default\").alias(\"team_abbrev\"),\n",
    "            pl.col(\"division_name\").alias(\"division\"),\n",
    "            pl.col(\"points\").alias(\"official\"),\n",
    "        ),\n",
    "        on=\"team_abbrev\",\n",
    "    )\n",
    "    .with_columns(above=pl.col(\"points\") - pl.col(\"game\"))\n",
    ")\n",
    "final = race.filter(pl.col(\"game\") == 82)\n",
    "assert (final[\"points\"] == final[\"official\"]).all()\n",
    "(\n",
    "    ggplot(race.to_pandas(), aes(\"game\", \"above\", color=\"team_abbrev\"))\n",
    "    + geom_line(size=0.7, show_legend=False)\n",
    "    + geom_sdv_logos(aes(team=\"team_abbrev\"), data=final.to_pandas(), league=\"nhl\", season=SEASON, height=0.09)\n",
    "    + scale_color_sdv(\"nhl\", season=SEASON)\n",
    "    + scale_x_continuous(breaks=[1, 20, 40, 60, 82], limits=(1, 88))\n",
    "    + facet_wrap(\"division\", ncol=2)\n",
    "    + labs(\n",
    "        x=\"Game\",\n",
    "        y=\"Points above a .500 pace\",\n",
    "        title=\"The 2025-26 points race, by division\",\n",
    "        caption=\"Data: fastRhockey via sportsdataverse-py\",\n",
    "    )\n",
    "    + theme_minimal()\n",
    "    + theme(figure_size=(10, 6), plot_title=element_text(weight=\"bold\"))\n",
    ")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "24",
   "metadata": {},
   "source": [
    "## 9. Interactive: expected-goal share against points\n",
    "\n",
    "The same logos in Plotly, so you can hover for the numbers: five-on-five expected-goal share (from example 3) against\n",
    "points percentage."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "25",
   "metadata": {},
   "outputs": [],
   "source": [
    "import plotly.graph_objects as go\n",
    "\n",
    "share = xg.join(\n",
    "    standings.select(\n",
    "        pl.col(\"team_abbrev_default\").alias(\"team_abbrev\"), pl.col(\"team_name_default\").alias(\"name\"), \"point_pctg\"\n",
    "    ),\n",
    "    on=\"team_abbrev\",\n",
    ").with_columns((100 * pl.col(\"xgf\") / (pl.col(\"xgf\") + pl.col(\"xga\"))).alias(\"xg_share\"))\n",
    "fig = go.Figure(\n",
    "    go.Scatter(\n",
    "        x=share[\"xg_share\"],\n",
    "        y=share[\"point_pctg\"],\n",
    "        mode=\"markers\",\n",
    "        marker={\"opacity\": 0},\n",
    "        text=share[\"name\"],\n",
    "        hovertemplate=\"%{text}<br>5v5 xG share %{x:.1f}%<br>Points %{y:.3f}<extra></extra>\",\n",
    "    )\n",
    ")\n",
    "fig = sdvplot.add_logos(\n",
    "    fig, share[\"xg_share\"], share[\"point_pctg\"], share[\"team_abbrev\"], league=\"nhl\", season=SEASON, height=0.08\n",
    ")\n",
    "fig.update_layout(\n",
    "    title=\"5v5 expected-goal share and points percentage, 2025-26\",\n",
    "    template=\"plotly_white\",\n",
    "    xaxis_title=\"5v5 xG share (%)\",\n",
    "    yaxis_title=\"Points percentage\",\n",
    "    width=800,\n",
    "    height=560,\n",
    ")\n",
    "fig"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "26",
   "metadata": {},
   "source": [
    "## 10. Playoff tiers\n",
    "\n",
    "A tier list of how far each team went in the 2026 playoffs, regular-season points deciding the order within a tier.\n",
    "The playoff round is the seventh digit of an NHL playoff game id (`2025030416` is round 4, series 1, game 6)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "27",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Every NHL team's logo in a tier by how far it went in the 2026 playoffs",
     "title": "NHL playoff tier list"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "from sdvplot.matplotlib import team_tiers\n",
    "\n",
    "playoffs = team_box.filter(pl.col(\"game_type\") == 3).with_columns(\n",
    "    rnd=(pl.col(\"game_id\") // 100 % 10), win=(pl.col(\"goals\") > pl.col(\"goals_against\")).cast(pl.Int32)\n",
    ")\n",
    "final_wins = playoffs.filter(pl.col(\"rnd\") == 4).group_by(\"team_abbrev\", maintain_order=True).agg(pl.col(\"win\").sum())\n",
    "champion = final_wins.filter(pl.col(\"win\") == 4)[\"team_abbrev\"].item()\n",
    "reached = playoffs.group_by(\"team_abbrev\", maintain_order=True).agg(pl.col(\"rnd\").max())\n",
    "tiers = (\n",
    "    standings.select(pl.col(\"team_abbrev_default\").alias(\"team\"), \"points\")\n",
    "    .join(reached, left_on=\"team\", right_on=\"team_abbrev\", how=\"left\")\n",
    "    .with_columns(\n",
    "        tier_no=pl.when(pl.col(\"team\") == champion)\n",
    "        .then(1)\n",
    "        .when(pl.col(\"rnd\").is_null())\n",
    "        .then(6)\n",
    "        .otherwise(6 - pl.col(\"rnd\"))\n",
    "    )\n",
    "    .sort(\"tier_no\", pl.col(\"points\"), descending=[False, True])\n",
    ")\n",
    "fig = team_tiers(\n",
    "    tiers,\n",
    "    \"nhl\",\n",
    "    title=\"2026 Stanley Cup playoffs: how far everyone got\",\n",
    "    subtitle=f\"{champion} won the Cup; teams ordered by regular-season points within each tier\",\n",
    "    caption=\"Data: fastRhockey via sportsdataverse-py\",\n",
    "    tier_desc={1: \"Champion\", 2: \"Final\", 3: \"Conference final\", 4: \"Second round\", 5: \"First round\", 6: \"Missed\"},\n",
    "    height=0.07,\n",
    ")\n",
    "fig.set_size_inches(10, 6)\n",
    "plt.show()"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "sdvplot": {
   "description": "Standings, expected goals, a shot map on the rink, scoring leaders with headshots, relocations and a playoff tier list from the 2025-26 NHL season.",
   "label": "NHL",
   "position": 40
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
