{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "0",
   "metadata": {},
   "source": [
    "# NHL\n",
    "\n",
    "This page is regenerated every week by sdvplot's docs workflow. It builds the standings, charts goal differential with logos and ranks the scoring leaders with their\n",
    "headshots, for the latest NHL season with games: the season to date from October to April, the final regular season\n",
    "once it is over. Data: the fastRhockey box-score release, the NHL's api-web.nhle.com standings and ESPN's leaders,\n",
    "read through [sportsdataverse-py](https://py.sportsdataverse.org/)."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1",
   "metadata": {},
   "source": [
    "NHL seasons are named by the year they end (2025-26 is `2026`) and start in the fall, so until September the calendar\n",
    "points at the season that ended in June. For a season that is not published yet `load_nhl_team_box` warns and\n",
    "returns an empty frame rather than raising, so the helper below turns \"no regular-season games\" into a `NoDataError`\n",
    "and the page steps back one season. A game id's fifth and sixth digits are its type: 02 regular season, 03 playoffs."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "2",
   "metadata": {},
   "outputs": [],
   "source": [
    "import datetime as dt\n",
    "import warnings\n",
    "\n",
    "import matplotlib.pyplot as plt\n",
    "import polars as pl\n",
    "import sportsdataverse.nhl as nhl\n",
    "from IPython.display import Markdown, display\n",
    "from sportsdataverse.errors import NoDataError\n",
    "\n",
    "import sdvplot\n",
    "\n",
    "today = dt.date.today()\n",
    "current = today.year + 1 if today.month >= 9 else today.year\n",
    "\n",
    "\n",
    "def label(season):\n",
    "    return f\"{season - 1}-{season % 100:02d}\"\n",
    "\n",
    "\n",
    "def team_games(season):\n",
    "    with warnings.catch_warnings():\n",
    "        warnings.simplefilter(\"ignore\")  # \"no data for season(s)\": handled just below\n",
    "        box = nhl.load_nhl_team_box(seasons=[season])\n",
    "    if box.is_empty():\n",
    "        raise NoDataError(f\"no {label(season)} games yet\")\n",
    "    box = box.with_columns(game_type=pl.col(\"game_id\") // 10_000 % 100, game_date=pl.col(\"game_date\").str.to_date())\n",
    "    if box.filter(pl.col(\"game_type\") == 2).is_empty():\n",
    "        raise NoDataError(f\"no {label(season)} regular-season games yet\")\n",
    "    return box\n",
    "\n",
    "\n",
    "try:\n",
    "    season, box = current, team_games(current)\n",
    "except NoDataError as err:\n",
    "    print(f\"{err}; showing {label(current - 1)} instead\")\n",
    "    season, box = current - 1, team_games(current - 1)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3",
   "metadata": {},
   "source": [
    "During the season the standings come from api-web.nhle.com as of today; once the regular season is over, as of its\n",
    "last day. The status line says which."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "4",
   "metadata": {},
   "outputs": [],
   "source": [
    "last_regular = box.filter(pl.col(\"game_type\") == 2)[\"game_date\"].max()\n",
    "playoffs = box.filter(pl.col(\"game_type\") == 3)\n",
    "in_season = season == current and playoffs.is_empty()\n",
    "standings = nhl.nhl_standings(\"now\" if in_season else str(last_regular))\n",
    "games = standings[\"games_played\"].sum() // 2\n",
    "lo, hi = standings[\"games_played\"].min(), standings[\"games_played\"].max()\n",
    "games_in = f\"{lo}-{hi}\" if lo != hi else f\"{hi}\"  # games per team: early in the season they differ\n",
    "if in_season:\n",
    "    status = (\n",
    "        f\"**Updated {today}:** the {label(season)} season, {games_in} games in per team ({games} games played). \"\n",
    "        \"Early-season tables move a lot from week to week.\"\n",
    "    )\n",
    "    through = f\"through {today:%b} {today.day} ({games_in} games in)\"\n",
    "elif season < current:\n",
    "    status = f\"**Offseason:** the final {label(season)} regular season; the {label(current)} season has no games yet.\"\n",
    "    through = \"final regular season\"\n",
    "elif (today - playoffs[\"game_date\"].max()).days <= 10:\n",
    "    status = f\"**Playoffs:** the final {label(season)} regular season; the playoffs are under way.\"\n",
    "    through = \"final regular season\"\n",
    "else:\n",
    "    status = f\"**Offseason:** the final {label(season)} regular season.\"\n",
    "    through = \"final regular season\"\n",
    "display(Markdown(status))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5",
   "metadata": {},
   "source": [
    "## 1. Standings\n",
    "\n",
    "Grouped by division in the NHL's own order. `gt_sdv_logos` turns the NHL's team codes into logos; the codes resolve\n",
    "through the index, so nothing is mapped by hand."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6",
   "metadata": {},
   "outputs": [],
   "source": [
    "from great_tables import GT\n",
    "\n",
    "from sdvplot.great_tables import gt_save_crop, gt_sdv_logos, gt_theme_swiss\n",
    "\n",
    "table = standings.sort(\"division_name\", \"division_sequence\").select(\n",
    "    division=\"division_name\",\n",
    "    logo=\"team_abbrev_default\",\n",
    "    team=\"team_common_name_default\",\n",
    "    gp=\"games_played\",\n",
    "    w=\"wins\",\n",
    "    l=\"losses\",\n",
    "    otl=\"ot_losses\",\n",
    "    pts=\"points\",\n",
    "    pts_pct=\"point_pctg\",\n",
    "    gf=\"goal_for\",\n",
    "    ga=\"goal_against\",\n",
    "    diff=\"goal_differential\",\n",
    "    l10=pl.format(\"{}-{}-{}\", \"l10_wins\", \"l10_losses\", \"l10_ot_losses\"),\n",
    "    strk=pl.format(\"{}{}\", \"streak_code\", \"streak_count\"),\n",
    ")\n",
    "gt = (\n",
    "    GT(table, groupname_col=\"division\", id=\"nhl-standings\")  # fixed id: no random one each run\n",
    "    .tab_header(f\"NHL standings, {label(season)}\", through[:1].upper() + through[1:])\n",
    "    .fmt_number(\"pts_pct\", decimals=3)\n",
    "    .fmt_number(\"diff\", decimals=0, force_sign=True)\n",
    "    .cols_align(\"left\", \"team\")\n",
    "    .cols_label(\n",
    "        logo=\"\",\n",
    "        team=\"Team\",\n",
    "        gp=\"GP\",\n",
    "        w=\"W\",\n",
    "        l=\"L\",\n",
    "        otl=\"OTL\",\n",
    "        pts=\"PTS\",\n",
    "        pts_pct=\"PTS%\",\n",
    "        gf=\"GF\",\n",
    "        ga=\"GA\",\n",
    "        diff=\"DIFF\",\n",
    "        l10=\"Last 10\",\n",
    "        strk=\"Streak\",\n",
    "    )\n",
    "    .tab_source_note(\"Data: api-web.nhle.com via sportsdataverse-py\")\n",
    ")\n",
    "gt = gt_theme_swiss(gt_sdv_logos(gt, \"logo\", league=\"nhl\", height=24))\n",
    "gt"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7",
   "metadata": {},
   "source": [
    "`gt_save_crop` renders the same table to a trimmed PNG, ready to post."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "NHL standings table grouped by division with team logos, points, goal differential and last ten games.",
     "title": "NHL standings"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "gt_save_crop(gt, width=900)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9",
   "metadata": {},
   "source": [
    "## 2. Goal differential\n",
    "\n",
    "Goals for minus goals against, all 32 teams, in team colors; `axis_logos` swaps the team codes on the x axis for\n",
    "logos."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "10",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Bars of every NHL team's goal differential this season in team colors, team logos on the x axis.",
     "title": "NHL goal differential"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "gd = standings.sort([\"goal_differential\", \"team_abbrev_default\"], descending=[True, False])  # ties: by code\n",
    "fig, ax = plt.subplots(figsize=(10, 5.5))\n",
    "ax.bar(\n",
    "    gd[\"team_abbrev_default\"],\n",
    "    gd[\"goal_differential\"],\n",
    "    color=sdvplot.team_colors(gd[\"team_abbrev_default\"].to_list(), \"nhl\", season=season),\n",
    ")\n",
    "ax.axhline(0, color=\"#222222\", lw=0.8)\n",
    "ax.margins(x=0.01)\n",
    "ax.set_ylabel(\"Goal differential\")\n",
    "ax.spines[[\"top\", \"right\"]].set_visible(False)\n",
    "ax.set_title(f\"NHL goal differential, {label(season)} {through}\", 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",
    "sdvplot.axis_logos(ax, \"x\", league=\"nhl\", season=season, height=0.06)\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "11",
   "metadata": {},
   "source": [
    "## 3. Scoring leaders with headshots\n",
    "\n",
    "ESPN's leaders feed (`season_type=2`, the regular season) carries ESPN athlete ids, which is what `add_headshots`\n",
    "needs. Goals and assists stack into points; the team logo sits at the end of each bar. The feed falls back to its\n",
    "current season when asked for one it does not have, so check `requestedSeason` before using it."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "12",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Top ten NHL scorers 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",
    "assert raw[\"requestedSeason\"][\"year\"] == season, raw[\"requestedSeason\"]\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\", descending=True, maintain_order=True).head(10).reverse()  # ties: ESPN order\n",
    "\n",
    "top = leaders[\"points\"].max()\n",
    "fig, ax = plt.subplots(figsize=(9, 6.5))\n",
    "y = list(range(leaders.height))\n",
    "ax.barh(y, leaders[\"goals\"], color=\"#1f3b73\", height=0.7, label=\"Goals\")\n",
    "ax.barh(y, leaders[\"assists\"], left=leaders[\"goals\"], color=\"#9fb4d8\", height=0.7, label=\"Assists\")\n",
    "ax.set_yticks(y, [f\"{p}  \" for p in leaders[\"player\"]])\n",
    "ax.set_xlim(-0.14 * top, 1.3 * top)\n",
    "sdvplot.add_headshots(ax, [-0.07 * top] * leaders.height, y, leaders[\"player_id\"], league=\"nhl\", height=0.085)\n",
    "sdvplot.add_logos(\n",
    "    ax, (leaders[\"points\"] + 0.07 * top).to_list(), y, leaders[\"team\"], league=\"nhl\", season=season, height=0.06\n",
    ")\n",
    "for i, p in enumerate(leaders[\"points\"]):\n",
    "    ax.text(p + 0.14 * top, i, f\"{p:.0f}\", va=\"center\", fontsize=10, fontweight=\"bold\")\n",
    "ax.spines[[\"top\", \"right\", \"left\"]].set_visible(False)\n",
    "ax.tick_params(axis=\"y\", length=0)\n",
    "ax.set_xlabel(\"Points\")\n",
    "ax.legend(loc=\"lower right\", frameon=False)\n",
    "ax.set_title(f\"NHL points leaders, {label(season)} {through}\", 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()"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  },
  "sdvplot": {
   "description": "The latest NHL season, rebuilt every week: standings, goal differential with logos and the points leaders with headshots.",
   "label": "NHL",
   "position": 6
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
