{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "0",
   "metadata": {},
   "source": [
    "# NHL shot map\n",
    "\n",
    "**The brief:** the morning after the Stanley Cup clincher, post one image of the game: every unblocked shot on a\n",
    "rink, each team in its colors, the goals called out, and the score. It goes out at 1200 x 675 for X and Bluesky and\n",
    "1600 x 900 for the blog recap. The play-by-play is the NHL's own game feed (api-web.nhle.com) through\n",
    "`sportsdataverse.nhl`, and `sdvplot.surface` draws the rink."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1",
   "metadata": {},
   "outputs": [],
   "source": [
    "import datetime as dt\n",
    "import tempfile\n",
    "from pathlib import Path\n",
    "\n",
    "import matplotlib.patheffects as pe\n",
    "import matplotlib.pyplot as plt\n",
    "import polars as pl\n",
    "import sportsdataverse.nhl as nhl\n",
    "from IPython.display import Image\n",
    "from matplotlib.lines import Line2D\n",
    "from PIL import Image as PILImage\n",
    "\n",
    "import sdvplot\n",
    "\n",
    "GAME_ID = 2025030416  # 2025-26 Stanley Cup Final, Game 6\n",
    "OUT = Path(tempfile.mkdtemp(prefix=\"sdvplot-recipe-\"))  # where the exports go; use your own folder"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2",
   "metadata": {},
   "source": [
    "## 1. Get the data\n",
    "\n",
    "One request returns the whole game feed. `parse_nhl_web_pbp` turns its plays into a frame, and the raw payload\n",
    "still has the teams, the final score and the rosters (for the scorers' names). The feed identifies teams by NHL\n",
    "stats ids, which arrive as floats; they become integers once, at the boundary, and `resolve` maps them with\n",
    "`id_system=\"nhl_id\"`. That id system is never tried automatically, because NHL ids 1-28 collide with other teams'\n",
    "ESPN ids, so it has to be named."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3",
   "metadata": {},
   "outputs": [],
   "source": [
    "raw = nhl.nhl_web_pbp(GAME_ID, return_parsed=False)\n",
    "plays = nhl.parse_nhl_web_pbp(raw)\n",
    "home, away = raw[\"homeTeam\"], raw[\"awayTeam\"]\n",
    "names = {p[\"playerId\"]: p[\"lastName\"][\"default\"] for p in raw[\"rosterSpots\"]}\n",
    "\n",
    "shots = plays.filter(pl.col(\"type_desc_key\").is_in([\"shot-on-goal\", \"missed-shot\", \"goal\"])).select(\n",
    "    \"time_in_period\",\n",
    "    \"home_team_defending_side\",\n",
    "    kind=\"type_desc_key\",\n",
    "    period=\"period_descriptor_number\",\n",
    "    nhl_id=pl.col(\"details_event_owner_team_id\").cast(pl.Int64),\n",
    "    x=\"details_x_coord\",\n",
    "    y=\"details_y_coord\",\n",
    "    scorer=pl.col(\"details_scoring_player_id\").cast(pl.Int64),\n",
    ")\n",
    "team_ids = sdvplot.resolve(shots[\"nhl_id\"].to_list(), \"nhl\", id_system=\"nhl_id\")\n",
    "abbr = dict(sdvplot.teams(\"nhl\").select(\"team_id\", \"abbr\").iter_rows())\n",
    "shots = shots.with_columns(team=pl.Series([abbr[t] for t in team_ids]))\n",
    "print(f\"{away['abbrev']} {away['score']} at {home['abbrev']} {home['score']}\")\n",
    "shots.group_by(\"team\", \"kind\", maintain_order=True).len().sort(\"team\", \"kind\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4",
   "metadata": {},
   "source": [
    "## 2. The first draft\n",
    "\n",
    "Scatter the coordinates as they come, one color per team."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5",
   "metadata": {},
   "outputs": [],
   "source": [
    "fig, ax = plt.subplots(figsize=(9, 4.5))\n",
    "for team, g in shots.group_by(\"team\", maintain_order=True):\n",
    "    ax.scatter(g[\"x\"], g[\"y\"], label=team[0])\n",
    "ax.legend()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6",
   "metadata": {},
   "source": [
    "Both teams have shots at both ends: teams switch ends every period, and the feed records where on the ice each shot\n",
    "happened. With no rink, the dots float in space, and the goals look like every other shot.\n",
    "\n",
    "## 3. One end per team, on a rink\n",
    "\n",
    "Rotating every play by 180 degrees in the periods when the home team defends the right-hand net puts each team's\n",
    "shots at one end for the whole game: the home team shoots right, the visitors left. `sdvplot.surface(\"nhl\")` draws a\n",
    "regulation rink in the feed's coordinates (feet, center ice at 0, 0), so the points land in place."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7",
   "metadata": {},
   "outputs": [],
   "source": [
    "flip = pl.when(pl.col(\"home_team_defending_side\") == \"right\").then(-1).otherwise(1)\n",
    "shots = shots.with_columns(x=pl.col(\"x\") * flip, y=pl.col(\"y\") * flip)\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(9, 4.5))\n",
    "sdvplot.surface(\"nhl\", ax=ax)\n",
    "for team, g in shots.group_by(\"team\", maintain_order=True):\n",
    "    ax.scatter(g[\"x\"], g[\"y\"], label=team[0], zorder=20)\n",
    "ax.legend()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8",
   "metadata": {},
   "source": [
    "## 4. Team colors, shot types and the goals\n",
    "\n",
    "Each team's shots take its primary color from `team_colors`; a shot on goal is filled, a miss is an open ring, and a\n",
    "goal is a large star with the scorer's name, in a white halo so it reads over the shots and the rink lines. The\n",
    "same drawing goes in a function so the final layout can reuse it."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9",
   "metadata": {},
   "outputs": [],
   "source": [
    "def draw_shots(ax):\n",
    "    sdvplot.surface(\"nhl\", ax=ax)\n",
    "    for (team,), g in shots.group_by(\"team\", maintain_order=True):\n",
    "        color = sdvplot.team_colors(team, \"nhl\")\n",
    "        on_goal, missed, goals = (g.filter(pl.col(\"kind\") == k) for k in (\"shot-on-goal\", \"missed-shot\", \"goal\"))\n",
    "        ax.scatter(on_goal[\"x\"], on_goal[\"y\"], s=34, color=color, alpha=0.85, lw=0, zorder=20)\n",
    "        ax.scatter(missed[\"x\"], missed[\"y\"], s=30, facecolor=\"none\", edgecolor=color, lw=1.2, zorder=20)\n",
    "        ax.scatter(goals[\"x\"], goals[\"y\"], s=190, color=color, edgecolor=\"white\", lw=1.6, marker=\"*\", zorder=22)\n",
    "        for i, row in enumerate(goals.iter_rows(named=True)):\n",
    "            above = i % 2 == 0  # alternate above and below, so goals close together keep their labels apart\n",
    "            ax.annotate(\n",
    "                f\"{names[row['scorer']]} (P{row['period']} {row['time_in_period']})\",\n",
    "                (row[\"x\"], row[\"y\"]),\n",
    "                xytext=(0, 9 if above else -9),\n",
    "                textcoords=\"offset points\",\n",
    "                ha=\"center\",\n",
    "                va=\"bottom\" if above else \"top\",\n",
    "                fontsize=7.5,\n",
    "                fontweight=\"bold\",\n",
    "                color=color,\n",
    "                zorder=23,\n",
    "                path_effects=[pe.withStroke(linewidth=2.5, foreground=\"white\")],\n",
    "            )\n",
    "\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(9, 4.5))\n",
    "draw_shots(ax)\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "10",
   "metadata": {},
   "source": [
    "## 5. The frame: score, teams and story\n",
    "\n",
    "A shot map needs a scoreboard. Each team gets a block on the side of the rink it attacks: its logo (`add_logos` on a\n",
    "small axes), its score and its official shots on goal from the feed. The title says what happened, the subtitle how to read the rink, and a\n",
    "legend row explains the three marks. The layout is set in inches, so the same function serves both export sizes."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "11",
   "metadata": {},
   "outputs": [],
   "source": [
    "GREY = \"#6b6b6b\"\n",
    "winner, loser = (away, home) if away[\"score\"] > home[\"score\"] else (home, away)\n",
    "day = dt.date.fromisoformat(raw[\"gameDate\"])\n",
    "played = f\"{day:%B} {day.day}, {day.year}\"\n",
    "\n",
    "\n",
    "def team_block(fig, rect, team):\n",
    "    ax = fig.add_axes(rect)\n",
    "    ax.set(xlim=(0, 1), ylim=(0, 1))\n",
    "    ax.axis(\"off\")\n",
    "    sdvplot.add_logos(ax, [0.5], [0.72], [team[\"abbrev\"]], league=\"nhl\", season=2026, height=0.36)\n",
    "    ax.text(0.5, 0.36, str(team[\"score\"]), ha=\"center\", va=\"center\", fontsize=30, fontweight=\"bold\")\n",
    "    ax.text(0.5, 0.14, f\"{team['sog']} shots on goal\", ha=\"center\", va=\"center\", fontsize=8, color=GREY)\n",
    "\n",
    "\n",
    "def shot_map(figsize, dpi=100):\n",
    "    w, h = figsize\n",
    "    fig = plt.figure(figsize=figsize, dpi=dpi, facecolor=\"white\")\n",
    "    side = 1.25  # inches for each team block\n",
    "    rink_h = h - 1.75\n",
    "    rink_w = min(w - 2 * side - 0.2, rink_h * 212.5 / 108.5)\n",
    "    left = (w - rink_w) / 2\n",
    "    ax = fig.add_axes((left / w, 0.55 / h, rink_w / w, rink_h / h))\n",
    "    draw_shots(ax)\n",
    "    block_h = 2.1 / h\n",
    "    team_block(fig, ((left - side) / w, 0.5 - block_h / 2 - 0.1 / h, side / w, block_h), away)  # shoots left\n",
    "    team_block(fig, ((left + rink_w) / w, 0.5 - block_h / 2 - 0.1 / h, side / w, block_h), home)  # shoots right\n",
    "\n",
    "    fig.text(\n",
    "        0.25 / w,\n",
    "        1 - 0.22 / h,\n",
    "        f\"{winner['commonName']['default']} win the Stanley Cup, {winner['score']}-{loser['score']} in Game 6\",\n",
    "        fontsize=15,\n",
    "        fontweight=\"bold\",\n",
    "        va=\"top\",\n",
    "    )\n",
    "    fig.text(\n",
    "        0.25 / w,\n",
    "        1 - 0.62 / h,\n",
    "        f\"Every unblocked shot, {played} at {raw['venue']['default']}. \"\n",
    "        f\"{away['abbrev']} shoots left, {home['abbrev']} right.\",\n",
    "        fontsize=9.5,\n",
    "        color=GREY,\n",
    "        va=\"top\",\n",
    "    )\n",
    "    marks = [\n",
    "        Line2D([], [], ls=\"\", marker=\"o\", color=GREY, ms=6, label=\"Shot on goal\"),\n",
    "        Line2D([], [], ls=\"\", marker=\"o\", mfc=\"none\", mec=GREY, ms=6, label=\"Missed\"),\n",
    "        Line2D([], [], ls=\"\", marker=\"*\", color=GREY, mec=\"white\", ms=12, label=\"Goal\"),\n",
    "    ]\n",
    "    fig.legend(\n",
    "        handles=marks,\n",
    "        loc=\"lower left\",\n",
    "        bbox_to_anchor=(0.2 / w, 0.05 / h),\n",
    "        ncol=3,\n",
    "        frameon=False,\n",
    "        fontsize=8,\n",
    "        handletextpad=0.3,\n",
    "        columnspacing=1.2,\n",
    "    )\n",
    "    fig.text(\n",
    "        1 - 0.25 / w,\n",
    "        0.14 / h,\n",
    "        \"Data: NHL api-web via sportsdataverse-py  |  Chart: sdvplot\",\n",
    "        fontsize=7.5,\n",
    "        color=GREY,\n",
    "        ha=\"right\",\n",
    "    )\n",
    "    return fig\n",
    "\n",
    "\n",
    "fig = shot_map((8, 4.5))\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "12",
   "metadata": {},
   "source": [
    "## 6. Export for X and the blog\n",
    "\n",
    "1200 x 675 for X and Bluesky and 1600 x 900 for the blog are the same 16:9 shape, so the 8 x 4.5 in figure from the\n",
    "last step serves both; only the dpi changes (150 and 200). Inches times dpi gives the exact pixels, and the logos,\n",
    "sized as a fraction of their axes, scale with everything else. (Saving the figure already drawn also skips\n",
    "redrawing the rink, the slowest part of this notebook.)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "13",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Shot map of the 2026 Stanley Cup Final Game 6 on a rink: Carolina's shots in red at the left end, Vegas's at the right, goals as stars with scorers, logos and the score at each side",
     "title": "NHL shot map, Stanley Cup clincher"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "files = {\"nhl_shot_map_1200x675.png\": 150, \"nhl_shot_map_1600x900.png\": 200}\n",
    "for name, dpi in files.items():\n",
    "    fig.savefig(OUT / name, dpi=dpi)\n",
    "    print(name, PILImage.open(OUT / name).size)\n",
    "Image(OUT / \"nhl_shot_map_1200x675.png\")"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  },
  "sdvplot": {
   "description": "Map every unblocked shot of the Stanley Cup clincher on a rink in team colors, with the goals, a scoreboard and exports at 1200x675 and 1600x900.",
   "label": "NHL shot map",
   "position": 6
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
