{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "0",
   "metadata": {},
   "source": [
    "# Surfaces and shot charts\n",
    "\n",
    "Nine recipes for playing surfaces and the charts drawn on them: `surface()` for every sport it supports, team\n",
    "colors and a center logo, NBA and WNBA shot charts from the stats-API shot files (converted with\n",
    "`court_coords`), a vertical half court, a hockey goal map, a baseball field and a soccer shot map on an\n",
    "mplsoccer pitch. Surfaces are drawn by sportypy. The data is one season each from the NFL (nflverse), the NBA, WNBA and college\n",
    "basketball (hoopR, wehoop and the stats-API shot files the SportsDataverse publishes on GitHub), the NHL\n",
    "(fastRhockey) and the Premier League (ESPN), all through sportsdataverse-py; nothing calls stats.nba.com."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1",
   "metadata": {},
   "outputs": [],
   "source": [
    "import matplotlib.pyplot as plt\n",
    "import polars as pl\n",
    "import sportsdataverse.mbb as mbb\n",
    "import sportsdataverse.nba as nba\n",
    "import sportsdataverse.nfl as nfl\n",
    "import sportsdataverse.nhl as nhl\n",
    "import sportsdataverse.soccer as soccer\n",
    "import sportsdataverse.wbb as wbb\n",
    "import sportsdataverse.wnba as wnba\n",
    "\n",
    "import sdvplot\n",
    "\n",
    "NFL_SEASON = 2025  # nflverse names a season by the year it starts\n",
    "SEASON = 2026  # the 2026 WNBA season and the 2025-26 NBA, NHL and college basketball season"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2",
   "metadata": {},
   "source": [
    "## 1. Every surface sdvplot can draw\n",
    "\n",
    "`surface(league, team)` picks sportypy's surface for the league and paints it in the team's colors where the\n",
    "sport has team-colored parts: end zones, the lane and apron, the center line and boards. Baseball fields and\n",
    "soccer pitches have none, so the team is left out there. (The WNBA and college courts are in recipe 3.)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3",
   "metadata": {},
   "outputs": [],
   "source": [
    "examples = [\n",
    "    (\"nfl\", \"SEA\", \"NFL: Seattle\"),\n",
    "    (\"cfb\", \"84\", \"College football: Indiana\"),\n",
    "    (\"nba\", \"OKC\", \"NBA: Oklahoma City\"),\n",
    "    (\"nhl\", \"COL\", \"NHL: Colorado\"),\n",
    "    (\"mlb\", None, \"MLB\"),\n",
    "    (\"soccer\", None, \"Soccer\"),\n",
    "]\n",
    "fig, axes = plt.subplots(2, 3, figsize=(10, 5.5))\n",
    "for ax, (league, team, label) in zip(axes.flat, examples, strict=True):\n",
    "    sdvplot.surface(league, team, ax=ax)\n",
    "    ax.set_title(label, fontsize=9)\n",
    "fig.suptitle(\"sdvplot.surface(): one call per league\", x=0.01, ha=\"left\", fontweight=\"bold\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4",
   "metadata": {},
   "source": [
    "The college team is an ESPN id (84 is Indiana) and the pros are abbreviations; any value `resolve` understands\n",
    "works.\n",
    "\n",
    "## 2. The champion's field, with a logo at midfield\n",
    "\n",
    "`center_logo=True` puts the team's logo at the center of the surface (a quarter of the Axes tall); a number\n",
    "sets another height. The Super Bowl winner, read from nflverse's schedule:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5",
   "metadata": {},
   "outputs": [],
   "source": [
    "sb = nfl.load_nfl_schedule([NFL_SEASON]).filter(pl.col(\"game_type\") == \"SB\").row(0, named=True)\n",
    "home_won = sb[\"home_score\"] > sb[\"away_score\"]\n",
    "winner, loser = (sb[\"home_team\"], sb[\"away_team\"]) if home_won else (sb[\"away_team\"], sb[\"home_team\"])\n",
    "score = f\"{max(sb['home_score'], sb['away_score'])}-{min(sb['home_score'], sb['away_score'])}\"\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(8.5, 5))\n",
    "sdvplot.surface(\"nfl\", winner, ax=ax, center_logo=0.3)\n",
    "name = sdvplot.teams(\"nfl\").filter(pl.col(\"abbr\") == winner)[\"name\"].item()\n",
    "ax.set_title(f\"{name}: Super Bowl champions, {NFL_SEASON} season ({score} over {loser})\", loc=\"left\",\n",
    "             fontweight=\"bold\")  # fmt: skip\n",
    "fig.text(0.99, 0.02, \"Data: nflverse via sportsdataverse-py\", ha=\"right\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6",
   "metadata": {},
   "source": [
    "## 3. Every basketball court, in each league's top team\n",
    "\n",
    "NBA, WNBA and college courts differ in their lines (the three-point arc, the lane width), and sportypy draws\n",
    "each league's own. Each court here belongs to its league's best team: the most regular-season wins for the\n",
    "pros, the highest adjusted efficiency margin in college."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7",
   "metadata": {},
   "outputs": [],
   "source": [
    "def most_wins(box: pl.DataFrame) -> str:\n",
    "    regular = box.filter(pl.col(\"season_type\") == 2)\n",
    "    wins = regular.group_by(\"team_abbreviation\", maintain_order=True).agg(pl.col(\"team_winner\").sum())\n",
    "    return wins.sort([\"team_winner\", \"team_abbreviation\"], descending=[True, False])[\"team_abbreviation\"][0]\n",
    "\n",
    "\n",
    "def top_rated(ratings: pl.DataFrame) -> str:\n",
    "    return ratings.sort(\"adj_em\", descending=True)[\"team_id\"][0]\n",
    "\n",
    "\n",
    "courts = [\n",
    "    (\"nba\", most_wins(nba.load_nba_team_boxscore(seasons=[SEASON]))),\n",
    "    (\"wnba\", most_wins(wnba.load_wnba_team_boxscore(seasons=[SEASON]))),\n",
    "    (\"mbb\", top_rated(mbb.load_mbb_ratings(SEASON))),\n",
    "    (\"wbb\", top_rated(wbb.load_wbb_ratings(SEASON))),\n",
    "]\n",
    "fig, axes = plt.subplots(2, 2, figsize=(10, 6))\n",
    "for ax, (league, team) in zip(axes.flat, courts, strict=True):\n",
    "    sdvplot.surface(league, team, ax=ax, center_logo=0.3)\n",
    "    name = sdvplot.teams(league).filter(pl.col(\"team_id\") == sdvplot.resolve(team, league))[\"name\"].item()\n",
    "    ax.set_title(f\"{league.upper()}: {name}\", fontsize=10)\n",
    "fig.suptitle(\"Each league's best team, 2026\", x=0.01, ha=\"left\", fontweight=\"bold\")\n",
    "fig.text(0.99, 0.01, \"Data: hoopR and wehoop (ESPN) via sportsdataverse-py\", ha=\"right\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8",
   "metadata": {},
   "source": [
    "## 4. An NBA shot chart from the stats-API frame\n",
    "\n",
    "The stats API's shot locations (`LOC_X` / `LOC_Y`, `x_legacy` / `y_legacy` in sportsdataverse-py) are in tenths\n",
    "of a foot with the hoop at the origin. sportypy's court has its origin at center court, in feet.\n",
    "`court_coords` converts one to the other and adds `court_x` / `court_y`; every shot lands on the left half, so\n",
    "draw that half with `display_range=\"defense\"`. The shots come from the stats-API shot file the\n",
    "SportsDataverse publishes on GitHub, so nothing calls stats.nba.com. Nikola Jokic's season:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Made and missed field goals of Nikola Jokic on a half court in Denver Nuggets colors",
     "title": "Nikola Jokic shot chart on a Nuggets-colored court"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "shots = nba.load_nba_stats_shots(seasons=SEASON - 1)  # this loader takes the season's start year\n",
    "jokic = sdvplot.court_coords(shots.filter((pl.col(\"person_id\") == 203999) & (pl.col(\"season_type_id\") == \"2\")))\n",
    "print(jokic.select(\"x_legacy\", \"y_legacy\", \"court_x\", \"court_y\").head(3))\n",
    "made, missed = jokic.filter(pl.col(\"shot_result\") == \"Made\"), jokic.filter(pl.col(\"shot_result\") == \"Missed\")\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(7.5, 7))\n",
    "sdvplot.surface(\"nba\", \"DEN\", ax=ax, display_range=\"defense\")\n",
    "ax.scatter(missed[\"court_x\"], missed[\"court_y\"], marker=\"x\", s=14, linewidths=0.8, color=\"#3d3d3d\", alpha=0.6,\n",
    "           zorder=20, label=f\"Missed ({missed.height})\")  # fmt: skip\n",
    "ax.scatter(made[\"court_x\"], made[\"court_y\"], s=18, color=sdvplot.team_colors(\"DEN\", \"nba\", \"secondary\"),\n",
    "           edgecolors=\"black\", linewidths=0.4, zorder=21, label=f\"Made ({made.height})\")  # fmt: skip\n",
    "ax.legend(loc=\"upper center\", bbox_to_anchor=(0.5, 0.02), ncols=2, frameon=False)\n",
    "ax.set_title(f\"Nikola Jokic, every field goal attempt, 2025-26 regular season\\n\"\n",
    "             f\"{made.height / jokic.height:.1%} from the field\", loc=\"left\", fontweight=\"bold\")  # fmt: skip\n",
    "fig.text(0.99, 0.01, \"Data: NBA stats API shot file via sportsdataverse-py\", ha=\"right\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "10",
   "metadata": {},
   "source": [
    "## 5. The same for the WNBA\n",
    "\n",
    "stats.wnba.com uses the same frame, so `court_coords` works unchanged; `surface(\"wnba\")` draws the WNBA's\n",
    "court, whose three-point arc sits closer to the basket than the NBA's. The file has no season-type column, but\n",
    "the game id says it: `102...` is the regular season, `104...` the playoffs. Caitlin Clark's 2026 regular season:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "11",
   "metadata": {},
   "outputs": [],
   "source": [
    "clark = sdvplot.court_coords(\n",
    "    wnba.load_wnba_stats_shots(seasons=SEASON).filter(\n",
    "        (pl.col(\"person_id\") == 1642286) & pl.col(\"game_id\").str.starts_with(\"102\")  # 102: regular season\n",
    "    )\n",
    ").with_columns(three=pl.col(\"shot_value\") == 3)\n",
    "colors = {\"Made\": sdvplot.team_colors(\"IND\", \"wnba\"), \"Missed\": \"#9e9e9e\"}\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(7.5, 7))\n",
    "sdvplot.surface(\"wnba\", \"IND\", ax=ax, display_range=\"defense\")\n",
    "for result in (\"Missed\", \"Made\"):\n",
    "    group = clark.filter(pl.col(\"shot_result\") == result)\n",
    "    ax.scatter(group[\"court_x\"], group[\"court_y\"], s=16, color=colors[result], edgecolors=\"white\",\n",
    "               linewidths=0.3, alpha=0.85, zorder=20, label=f\"{result} ({group.height})\")  # fmt: skip\n",
    "threes = clark.filter(pl.col(\"three\"))\n",
    "ax.legend(loc=\"upper center\", bbox_to_anchor=(0.5, 0.02), ncols=2, frameon=False)\n",
    "ax.set_title(f\"Caitlin Clark's {clark.height} shots, 2026 regular season: {threes.height / clark.height:.0%} threes\",\n",
    "             loc=\"left\", fontweight=\"bold\")  # fmt: skip\n",
    "fig.text(0.99, 0.01, \"Data: WNBA stats API shot file via sportsdataverse-py\", ha=\"right\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "12",
   "metadata": {},
   "source": [
    "## 6. A vertical half court: rotate the surface and the data\n",
    "\n",
    "sportypy's `rotation=` turns the drawing; turn the points the same way. A quarter turn counterclockwise\n",
    "(`rotation=90`) maps each point (x, y) to (-y, x) and puts the basket at the bottom, the way many shot charts\n",
    "are drawn. `display_range` still names the half of the unrotated court. Luka Doncic's shots, made in the\n",
    "Lakers' gold:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "13",
   "metadata": {},
   "outputs": [],
   "source": [
    "luka = sdvplot.court_coords(shots.filter((pl.col(\"person_id\") == 1629029) & (pl.col(\"season_type_id\") == \"2\")))\n",
    "luka = luka.with_columns(x=-pl.col(\"court_y\"), y=pl.col(\"court_x\"))  # (x, y) -> (-y, x): a quarter turn\n",
    "made, missed = luka.filter(pl.col(\"shot_result\") == \"Made\"), luka.filter(pl.col(\"shot_result\") == \"Missed\")\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(7, 7))\n",
    "sdvplot.surface(\"nba\", \"LAL\", ax=ax, rotation=90, display_range=\"defense\")\n",
    "ax.scatter(missed[\"x\"], missed[\"y\"], marker=\"x\", s=12, linewidths=0.7, color=\"#555555\", alpha=0.5, zorder=20,\n",
    "           label=f\"Missed ({missed.height})\")  # fmt: skip\n",
    "ax.scatter(made[\"x\"], made[\"y\"], s=16, color=sdvplot.team_colors(\"LAL\", \"nba\", \"secondary\"), edgecolors=\"black\",\n",
    "           linewidths=0.4, zorder=21, label=f\"Made ({made.height})\")  # fmt: skip\n",
    "ax.legend(loc=\"upper center\", bbox_to_anchor=(0.5, 0.0), ncols=2, frameon=False)\n",
    "ax.set_title(f\"Luka Doncic's {luka.height:,} shots, 2025-26 regular season\", loc=\"left\", fontweight=\"bold\")\n",
    "fig.text(0.99, 0.01, \"Data: NBA stats API shot file via sportsdataverse-py\", ha=\"right\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "14",
   "metadata": {},
   "source": [
    "## 7. A hockey goal map on a team-colored rink\n",
    "\n",
    "fastRhockey's `x_fixed` / `y_fixed` are in feet on the NHL rink's frame, the same one sportypy draws, with the\n",
    "home team shooting right. Mirror the away goals (flip both signs) to put every goal at one net, then draw the\n",
    "attacking half with `display_range=\"offense\"`. Nathan MacKinnon's regular-season goals:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "15",
   "metadata": {},
   "outputs": [],
   "source": [
    "pbp = nhl.load_nhl_pbp_lite(seasons=[SEASON]).select(\n",
    "    \"event_type\", \"event_player_1_name\", \"season_type\", \"strength_state\", \"x_fixed\", \"y_fixed\"\n",
    ")\n",
    "goals = pbp.filter(\n",
    "    (pl.col(\"event_player_1_name\") == \"Nathan MacKinnon\")\n",
    "    & (pl.col(\"event_type\") == \"GOAL\")\n",
    "    & (pl.col(\"season_type\") == \"R\")\n",
    ").with_columns(\n",
    "    x=pl.col(\"x_fixed\").abs(),\n",
    "    y=pl.when(pl.col(\"x_fixed\") < 0).then(-pl.col(\"y_fixed\")).otherwise(pl.col(\"y_fixed\")),\n",
    "    power_play=pl.col(\"strength_state\").is_in([\"5v4\", \"5v3\", \"4v3\"]),\n",
    ")\n",
    "fig, ax = plt.subplots(figsize=(7, 7))\n",
    "sdvplot.surface(\"nhl\", \"COL\", ax=ax, display_range=\"offense\")\n",
    "for pp, label, marker in [(False, \"Even strength and other\", \"o\"), (True, \"Power play\", \"D\")]:\n",
    "    g = goals.filter(pl.col(\"power_play\") == pp)\n",
    "    ax.scatter(g[\"x\"], g[\"y\"], marker=marker, s=60, color=sdvplot.team_colors(\"COL\", \"nhl\"),\n",
    "               edgecolors=\"black\", alpha=0.85, zorder=30, label=f\"{label} ({g.height})\")  # fmt: skip\n",
    "ax.legend(loc=\"upper center\", bbox_to_anchor=(0.5, 0.02), ncols=2, frameon=False)\n",
    "ax.set_title(f\"Nathan MacKinnon's {goals.height} goals, 2025-26 regular season\", loc=\"left\", fontweight=\"bold\")\n",
    "fig.text(0.99, 0.01, \"Data: fastRhockey via sportsdataverse-py\", ha=\"right\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "16",
   "metadata": {},
   "source": [
    "## 8. A baseball field with a logo in center field\n",
    "\n",
    "Baseball fields have no team-colored lines, and the field's origin is home plate, so `center_logo` would put\n",
    "the logo on the plate. Draw the field, then place the logo yourself with `add_logos`: here in center field,\n",
    "for the team with MLB's best 2026 record (ESPN's final standings)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "17",
   "metadata": {},
   "outputs": [],
   "source": [
    "import sportsdataverse.mlb as mlb\n",
    "\n",
    "best = mlb.espn_mlb_standings(season=SEASON).sort(\"win_percent\", descending=True).row(0, named=True)\n",
    "ax = sdvplot.surface(\"mlb\")\n",
    "ax.figure.set_size_inches(7, 6)\n",
    "sdvplot.add_logos(ax, [0], [260], [best[\"team_abbreviation\"]], league=\"mlb\", height=0.18, zorder=40)\n",
    "ax.set_title(f\"{best['team_display_name']}: MLB's best record in {SEASON} ({best['wins']:.0f}-{best['losses']:.0f})\",\n",
    "             loc=\"left\", fontweight=\"bold\", color=\"white\")  # fmt: skip\n",
    "ax.figure.text(0.98, 0.02, \"Data: ESPN via sportsdataverse-py\", ha=\"right\", fontsize=8, color=\"white\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "18",
   "metadata": {},
   "source": [
    "## 9. A soccer shot map on an mplsoccer pitch\n",
    "\n",
    "For soccer, mplsoccer draws the pitch and sdvplot's logos go on its matplotlib Axes. ESPN's play-by-play gives\n",
    "each shot's position as fractions of the pitch: `field_position_x` measured from the goal the team attacks,\n",
    "`field_position_y` across. The home team shoots right, the away team left; goals are the scorer's logo. The\n",
    "Premier League's final day of 2025-26, Manchester City against Aston Villa (ESPN team ids 382 and 362):"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "19",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Every shot of Manchester City vs Aston Villa on a dark mplsoccer pitch, goals drawn as the scorer's logo",
     "title": "Premier League shot map with goal logos on an mplsoccer pitch"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "from mplsoccer import Pitch\n",
    "\n",
    "EVENT, HOME, AWAY = 740970, \"382\", \"362\"\n",
    "plays = soccer.espn_soccer_game_plays(\"eng.1\", EVENT, cid=EVENT)\n",
    "shots = (\n",
    "    plays.filter(pl.col(\"type_text\").str.contains(\"(?i)shot|goal\") & (pl.col(\"type_text\") != \"Assists Shot\"))\n",
    "    .with_columns(team=pl.col(\"team_$ref\").str.extract(r\"/teams/(\\d+)\"))\n",
    "    .with_columns(\n",
    "        x=pl.when(pl.col(\"team\") == HOME)\n",
    "        .then((1 - pl.col(\"field_position_x\")) * 105)\n",
    "        .otherwise(pl.col(\"field_position_x\") * 105),\n",
    "        y=pl.col(\"field_position_y\") * 68,\n",
    "    )\n",
    ")\n",
    "goals = shots.filter(pl.col(\"scoring_play\"))\n",
    "on_target = shots.filter(pl.col(\"type_text\") == \"Shot On Target\")\n",
    "other = shots.filter(~pl.col(\"scoring_play\") & (pl.col(\"type_text\") != \"Shot On Target\"))\n",
    "\n",
    "pitch = Pitch(pitch_type=\"custom\", pitch_length=105, pitch_width=68, pitch_color=\"#22312b\", line_color=\"#c7d5cc\")\n",
    "fig, ax = pitch.draw(figsize=(10, 6.5))\n",
    "pitch.scatter(other[\"x\"], other[\"y\"], s=70, facecolors=\"none\", edgecolors=\"#c7d5cc\", ax=ax,\n",
    "              label=\"Off target, blocked or post\")  # fmt: skip\n",
    "pitch.scatter(on_target[\"x\"], on_target[\"y\"], s=70, color=\"#c7d5cc\", ax=ax, label=\"Saved\")\n",
    "sdvplot.add_logos(ax, goals[\"x\"], goals[\"y\"], goals[\"team\"], league=\"soccer\", height=0.08, zorder=5)\n",
    "sdvplot.add_logos(ax, [8, 97], [74, 74], [AWAY, HOME], league=\"soccer\", height=0.1, zorder=5)\n",
    "ax.legend(loc=\"lower center\", bbox_to_anchor=(0.5, -0.06), ncols=2, frameon=False, labelcolor=\"white\")\n",
    "home_goals = goals.filter(pl.col(\"team\") == HOME).height\n",
    "ax.set_title(f\"Manchester City {home_goals}, Aston Villa {goals.height - home_goals}: every shot (logos: goals)\",\n",
    "             color=\"white\", fontweight=\"bold\", loc=\"left\")  # fmt: skip\n",
    "fig.set_facecolor(\"#22312b\")\n",
    "fig.text(0.99, 0.01, \"Data: ESPN via sportsdataverse-py\", ha=\"right\", fontsize=8, color=\"#c7d5cc\")\n",
    "plt.show()"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "sdvplot": {
   "description": "Nine recipes for playing surfaces: every sport's surface in team colors, center logos, NBA and WNBA shot charts with court_coords, a rotated court, a hockey goal map, a baseball field and a soccer shot map.",
   "label": "Surfaces and shot charts",
   "position": 6
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
