{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "0",
   "metadata": {},
   "source": [
    "# Colors and themes\n",
    "\n",
    "Nine recipes for team colors: a league's palette at a glance, one color per row of your data, readable text\n",
    "on team-colored cells, seaborn, colors that clash, a league-wide colormap, PyPalettes colormaps, morethemes\n",
    "styles, and the fallback colors to watch for. The data is one season each from the NFL (nflverse), the NBA\n",
    "(hoopR and the stats-API shot file the SportsDataverse publishes on GitHub) and the NHL (fastRhockey), all\n",
    "through sportsdataverse-py."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1",
   "metadata": {},
   "outputs": [],
   "source": [
    "import matplotlib.pyplot as plt\n",
    "import numpy as np\n",
    "import polars as pl\n",
    "import seaborn as sns\n",
    "import sportsdataverse.nba as nba\n",
    "import sportsdataverse.nfl as nfl\n",
    "import sportsdataverse.nhl as nhl\n",
    "from matplotlib.colors import ListedColormap, to_rgb\n",
    "\n",
    "import sdvplot\n",
    "\n",
    "NFL_SEASON = 2025  # nflverse names a season by the year it starts\n",
    "SEASON = 2026  # the 2025-26 NBA and NHL season, named by the year it ends\n",
    "HOOPR = \"Data: hoopR (ESPN) via sportsdataverse-py\"\n",
    "FASTRHOCKEY = \"Data: fastRhockey via sportsdataverse-py\"\n",
    "\n",
    "nba_box = nba.load_nba_team_boxscore(seasons=[SEASON]).filter(pl.col(\"season_type\") == 2)\n",
    "nhl_box = nhl.load_nhl_team_box(seasons=[SEASON]).filter(pl.col(\"game_id\") // 10_000 % 100 == 2)\n",
    "nba_box.height, nhl_box.height"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2",
   "metadata": {},
   "source": [
    "## 1. See a league's palette\n",
    "\n",
    "`palette(league)` is a plain `{team: \"#hex\"}` dict, `which=\"secondary\"` the other color. Laid out by division,\n",
    "with each team's logo, it is a quick check of what a chart will look like:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Swatches of each NFL team's primary and secondary colors beside its logo, in eight division columns",
     "title": "Every NFL team's primary and secondary color by division"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "divisions = nfl.load_nfl_teams().select(team=\"team_abbr\", division=\"team_division\")\n",
    "primary, secondary = sdvplot.palette(\"nfl\"), sdvplot.palette(\"nfl\", which=\"secondary\")\n",
    "teams = divisions.filter(pl.col(\"team\").is_in(list(primary))).sort(\"division\", \"team\")\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(10, 5.5))\n",
    "for col, (division, group) in enumerate(teams.group_by(\"division\", maintain_order=True)):\n",
    "    ax.text(col * 1.25 + 0.55, 4.25, division[0], ha=\"center\", fontsize=9, fontweight=\"bold\")\n",
    "    for row, team in enumerate(group[\"team\"]):\n",
    "        y = 3 - row\n",
    "        ax.add_patch(plt.Rectangle((col * 1.25 + 0.3, y + 0.15), 0.55, 0.7, color=primary[team]))\n",
    "        ax.add_patch(plt.Rectangle((col * 1.25 + 0.85, y + 0.15), 0.25, 0.7, color=secondary[team], ec=\"#999999\"))\n",
    "        sdvplot.add_logos(ax, [col * 1.25 + 0.1], [y + 0.5], [team], league=\"nfl\", height=0.08)\n",
    "ax.set_xlim(-0.15, 10)\n",
    "ax.set_ylim(-0.1, 4.5)\n",
    "ax.axis(\"off\")\n",
    "ax.set_title(\"NFL team colors: primary and secondary\", loc=\"left\", fontweight=\"bold\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4",
   "metadata": {},
   "source": [
    "## 2. One color per row of your data\n",
    "\n",
    "`team_colors` returns one color per value, in the container it was given: a polars Series in gives a Series\n",
    "out, ready for `with_columns`. Values in any id system work, and a team that does not resolve gets `None`.\n",
    "There are two colors per team; ask for any other and you get a `ValueError` that says which exist."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5",
   "metadata": {},
   "outputs": [],
   "source": [
    "top = (\n",
    "    nhl_box.group_by(\"team_abbrev\", maintain_order=True)\n",
    "    .agg(gf=pl.col(\"goals\").mean())\n",
    "    .sort([\"gf\", \"team_abbrev\"], descending=[True, False])\n",
    "    .head(5)\n",
    ")\n",
    "top = top.with_columns(\n",
    "    primary=sdvplot.team_colors(top[\"team_abbrev\"], \"nhl\"),\n",
    "    secondary=sdvplot.team_colors(top[\"team_abbrev\"], \"nhl\", which=\"secondary\"),\n",
    ")\n",
    "try:\n",
    "    sdvplot.team_colors(top[\"team_abbrev\"], \"nhl\", which=\"alternate\")\n",
    "except ValueError as e:\n",
    "    print(e)\n",
    "top"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6",
   "metadata": {},
   "source": [
    "## 3. Readable text on team-colored cells\n",
    "\n",
    "Text on a team color needs the right ink: white on navy, black on gold. great_tables' `data_color` picks it\n",
    "for you (`autocolor_text`, on by default), so fill a team column from `palette` and let it choose. sdvplot's\n",
    "own team-colored outputs do the same: `gt_theme_sdv_team`, `gt_tiers` and `surface()` choose a readable ink\n",
    "for each fill."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7",
   "metadata": {},
   "outputs": [],
   "source": [
    "from great_tables import GT\n",
    "\n",
    "west_ids = sdvplot.teams(\"nba\").filter(pl.col(\"conference\") == \"Western Conference\").select(\"team_id\")\n",
    "west = (\n",
    "    nba_box.with_columns(pl.col(\"team_id\").cast(pl.Int64).cast(pl.Utf8))\n",
    "    .join(west_ids, on=\"team_id\")\n",
    "    .group_by(\"team_abbreviation\", \"team_display_name\", maintain_order=True)\n",
    "    .agg(wins=pl.col(\"team_winner\").sum(), diff=(pl.col(\"team_score\") - pl.col(\"opponent_team_score\")).mean())\n",
    "    .sort([\"wins\", \"team_abbreviation\"], descending=[True, False])\n",
    ")\n",
    "colors = sdvplot.palette(\"nba\", teams=west[\"team_abbreviation\"])\n",
    "(\n",
    "    GT(west)\n",
    "    .tab_header(\"Western Conference, 2025-26\", \"Each team's cell in its primary color\")\n",
    "    .cols_label(team_abbreviation=\"\", team_display_name=\"Team\", wins=\"W\", diff=\"Point diff.\")\n",
    "    .fmt_number(\"diff\", decimals=1, force_sign=True)\n",
    "    .data_color(\"team_abbreviation\", palette=list(colors.values()), domain=list(colors))\n",
    "    .tab_source_note(HOOPR)\n",
    ")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8",
   "metadata": {},
   "source": [
    "## 4. A seaborn palette\n",
    "\n",
    "seaborn takes the `palette` dict for `hue`; key it by the same values as the hue column. Every regular-season\n",
    "goal margin of the Central Division, with each team's average:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9",
   "metadata": {},
   "outputs": [],
   "source": [
    "CENTRAL = [\"CHI\", \"COL\", \"DAL\", \"MIN\", \"NSH\", \"STL\", \"UTA\", \"WPG\"]\n",
    "central = nhl_box.filter(pl.col(\"team_abbrev\").is_in(CENTRAL)).with_columns(\n",
    "    margin=pl.col(\"goals\") - pl.col(\"goals_against\")\n",
    ")\n",
    "means = central.group_by(\"team_abbrev\", maintain_order=True).agg(pl.col(\"margin\").mean())\n",
    "order = means.sort([\"margin\", \"team_abbrev\"], descending=[True, False])[\"team_abbrev\"]\n",
    "palette = sdvplot.palette(\"nhl\", teams=central[\"team_abbrev\"])\n",
    "\n",
    "np.random.seed(2026)  # seaborn jitters from numpy's global random state: a seed keeps the chart the same each run\n",
    "fig, ax = plt.subplots(figsize=(10, 5.5))\n",
    "sns.stripplot(central.to_pandas(), x=\"team_abbrev\", y=\"margin\", hue=\"team_abbrev\", order=order.to_list(),\n",
    "              palette=palette, jitter=0.3, alpha=0.6, size=4, legend=False, ax=ax)  # fmt: skip\n",
    "sns.pointplot(central.to_pandas(), x=\"team_abbrev\", y=\"margin\", order=order.to_list(), color=\"black\",\n",
    "              linestyle=\"none\", markers=\"D\", errorbar=None, ax=ax)  # fmt: skip\n",
    "ax.axhline(0, color=\"grey\", linewidth=0.8)\n",
    "ax.set_xlabel(\"\")\n",
    "ax.set_ylabel(\"Goal margin (shootout goals not counted)\")\n",
    "ax.set_title(\"Central Division game margins, 2025-26 (diamond: average)\", loc=\"left\", fontweight=\"bold\")\n",
    "sdvplot.axis_logos(ax, \"x\", league=\"nhl\", height=0.08)\n",
    "fig.text(0.99, 0.01, FASTRHOCKEY, ha=\"right\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "10",
   "metadata": {},
   "source": [
    "## 5. When two teams' colors clash\n",
    "\n",
    "Some rivals share a color: the Lakers' and Kings' primaries are both purple. Measure the gap between two\n",
    "colors (a plain RGB distance does the job) and fall back to one team's secondary when it is too small."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "11",
   "metadata": {},
   "outputs": [],
   "source": [
    "def distance(a: str, b: str) -> float:\n",
    "    return sum((x - y) ** 2 for x, y in zip(to_rgb(a), to_rgb(b), strict=True)) ** 0.5\n",
    "\n",
    "\n",
    "lal, sac = sdvplot.team_colors([\"LAL\", \"SAC\"], \"nba\")\n",
    "print(f\"primaries {lal} vs {sac}: distance {distance(lal, sac):.2f}\")\n",
    "if distance(lal, sac) < 0.25:\n",
    "    sac = sdvplot.team_colors(\"SAC\", \"nba\", which=\"secondary\")\n",
    "    print(f\"using the Kings' secondary {sac}: distance {distance(lal, sac):.2f}\")\n",
    "\n",
    "race = (\n",
    "    nba_box.filter(pl.col(\"team_abbreviation\").is_in([\"LAL\", \"SAC\"]))\n",
    "    .sort(\"game_date\")\n",
    "    .with_columns(\n",
    "        game_no=pl.int_range(1, pl.len() + 1).over(\"team_abbreviation\"),\n",
    "        wins=pl.col(\"team_winner\").cast(pl.Int32).cum_sum().over(\"team_abbreviation\"),\n",
    "    )\n",
    ")\n",
    "fig, ax = plt.subplots(figsize=(9, 5))\n",
    "for team, color in {\"LAL\": lal, \"SAC\": sac}.items():\n",
    "    run = race.filter(pl.col(\"team_abbreviation\") == team)\n",
    "    ax.plot(run[\"game_no\"], run[\"wins\"], color=color, linewidth=2.5)\n",
    "    sdvplot.add_logos(ax, [run[\"game_no\"][-1] + 3], [run[\"wins\"][-1]], [team], league=\"nba\", height=0.09)\n",
    "ax.set_xlim(0, 90)\n",
    "ax.set_xlabel(\"Game\")\n",
    "ax.set_ylabel(\"Wins\")\n",
    "ax.spines[[\"top\", \"right\"]].set_visible(False)\n",
    "ax.set_title(\"Lakers and Kings, win by win, 2025-26\", loc=\"left\", fontweight=\"bold\")\n",
    "fig.text(0.99, 0.01, HOOPR, ha=\"right\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "12",
   "metadata": {},
   "source": [
    "A secondary is not always safe either: the Celtics' is white, which vanishes on a white chart. Check it the same\n",
    "way against the background.\n",
    "\n",
    "## 6. A league-wide colormap\n",
    "\n",
    "For an image (`imshow`) or anything else that maps numbers to colors, build a `ListedColormap` from the\n",
    "palette, one entry per team. Here every Eastern Conference game is one cell, in the team's color for a win\n",
    "and light grey for a loss: each team's season as a barcode, best record on top."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "13",
   "metadata": {},
   "outputs": [],
   "source": [
    "east_ids = sdvplot.teams(\"nba\").filter(pl.col(\"conference\") == \"Eastern Conference\").select(\"team_id\")\n",
    "east = (\n",
    "    nba_box.with_columns(pl.col(\"team_id\").cast(pl.Int64).cast(pl.Utf8))\n",
    "    .join(east_ids, on=\"team_id\")\n",
    "    .sort(\"game_date\")\n",
    "    .with_columns(game_no=pl.int_range(pl.len()).over(\"team_abbreviation\"))\n",
    ")\n",
    "order = (\n",
    "    east.group_by(\"team_abbreviation\", maintain_order=True)\n",
    "    .agg(pl.col(\"team_winner\").sum())\n",
    "    .sort([\"team_winner\", \"team_abbreviation\"], descending=[True, False])\n",
    ")[\"team_abbreviation\"].to_list()\n",
    "cmap = ListedColormap(sdvplot.team_colors(order, \"nba\") + [\"#e6e6e6\"])  # one color per team, then a loss\n",
    "\n",
    "grid = [[float(\"nan\")] * (east[\"game_no\"].max() + 1) for _ in order]\n",
    "for team, game_no, won in east.select(\"team_abbreviation\", \"game_no\", \"team_winner\").iter_rows():\n",
    "    row = order.index(team)\n",
    "    grid[row][game_no] = row if won else len(order)\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(10, 6))\n",
    "ax.imshow(grid, cmap=cmap, vmin=-0.5, vmax=len(order) + 0.5, aspect=\"auto\", interpolation=\"nearest\")\n",
    "ax.set_yticks(range(len(order)), order)\n",
    "ax.tick_params(axis=\"y\", length=0)\n",
    "sdvplot.axis_logos(ax, \"y\", league=\"nba\", height=0.055)\n",
    "ax.set_xlabel(\"Game\")\n",
    "ax.set_title(\"The Eastern Conference's 2025-26, game by game (color: a win, grey: a loss)\", loc=\"left\",\n",
    "             fontweight=\"bold\")  # fmt: skip\n",
    "fig.text(0.99, 0.01, HOOPR, ha=\"right\", fontsize=8, color=\"grey\")\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "14",
   "metadata": {},
   "source": [
    "## 7. A colormap from a team's colors with PyPalettes\n",
    "\n",
    "PyPalettes' `create_cmap` turns a list of colors into a matplotlib colormap; built from a pale tint and a\n",
    "team's two colors, it shades a density chart in that team. Shai Gilgeous-Alexander's shots, from the stats-API\n",
    "shot file the SportsDataverse publishes as a GitHub release (no stats.nba.com call), moved onto sportypy's\n",
    "court with `court_coords`:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "15",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Hexbin density of Shai Gilgeous-Alexander's shots on a half court, shaded pale blue to Thunder blue to orange",
     "title": "A shot-density map shaded in the Thunder's colors with PyPalettes"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "from pypalettes import create_cmap\n",
    "\n",
    "shots = nba.load_nba_stats_shots(seasons=SEASON - 1)  # this loader takes the season's start year\n",
    "sga = sdvplot.court_coords(shots.filter((pl.col(\"person_id\") == 1628983) & (pl.col(\"season_type_id\") == \"2\")))\n",
    "okc_primary, okc_secondary = sdvplot.team_colors(\"OKC\", \"nba\"), sdvplot.team_colors(\"OKC\", \"nba\", \"secondary\")\n",
    "cmap = create_cmap([\"#d6e8f5\", okc_primary, okc_secondary], cmap_type=\"continuous\")\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(7.5, 6.5))\n",
    "sdvplot.surface(\"nba\", ax=ax, display_range=\"defense\")\n",
    "hb = ax.hexbin(sga[\"court_x\"], sga[\"court_y\"], gridsize=(15, 18), extent=(-47, 0, -25, 25), mincnt=1,\n",
    "               bins=\"log\", cmap=cmap, linewidths=0.3, edgecolors=\"white\", zorder=20)  # fmt: skip\n",
    "fig.colorbar(hb, ax=ax, shrink=0.6, label=\"Shots (log scale)\")\n",
    "ax.set_title(f\"Shai Gilgeous-Alexander's {sga.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": "16",
   "metadata": {},
   "source": [
    "## 8. A matplotlib theme from morethemes, with team colors on top\n",
    "\n",
    "morethemes styles the whole figure: background, grid and a Google font that `set_theme` downloads and\n",
    "registers. `set_theme` changes matplotlib's global settings, so call it inside `plt.rc_context()` to style one\n",
    "chart and restore your defaults afterwards. Team colors and logos draw over the theme as usual."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "17",
   "metadata": {},
   "outputs": [],
   "source": [
    "import morethemes as mt\n",
    "\n",
    "net = (\n",
    "    nba_box.group_by(\"team_abbreviation\", maintain_order=True)\n",
    "    .agg(games=pl.len(), diff=(pl.col(\"team_score\") - pl.col(\"opponent_team_score\")).mean())\n",
    "    .filter(pl.col(\"games\") > 10)\n",
    "    .sort([\"diff\", \"team_abbreviation\"], descending=[True, False])\n",
    "    .head(10)\n",
    "    .reverse()\n",
    ")\n",
    "with plt.rc_context():\n",
    "    mt.set_theme(\"economist\")\n",
    "    fig, ax = plt.subplots(figsize=(9, 5.5))\n",
    "    ax.barh(net[\"team_abbreviation\"], net[\"diff\"], color=sdvplot.team_colors(net[\"team_abbreviation\"], \"nba\"))\n",
    "    sdvplot.axis_logos(ax, \"y\", league=\"nba\", height=0.07)\n",
    "    ax.set_xlabel(\"Average point differential per game\")\n",
    "    ax.set_title(\"The NBA's top ten by point differential, 2025-26\", loc=\"left\", fontweight=\"bold\")\n",
    "    fig.subplots_adjust(bottom=0.17)\n",
    "    fig.text(0.99, 0.01, HOOPR, ha=\"right\", fontsize=8)\n",
    "    plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "18",
   "metadata": {},
   "source": [
    "## 9. Spot fallback colors, and supply your own\n",
    "\n",
    "Not every league has official colors in the index. Where none exist, `color_source` is `\"fallback\"` and the\n",
    "color only keeps teams apart. Check it before you call a color a team's own; for a published chart, override\n",
    "the fallbacks with the clubs' real colors in your own dict."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "19",
   "metadata": {},
   "outputs": [],
   "source": [
    "share = (\n",
    "    sdvplot.teams()\n",
    "    .group_by(\"league\", maintain_order=True)\n",
    "    .agg(teams=pl.len(), fallback=(pl.col(\"color_source\") == \"fallback\").mean())\n",
    "    .sort(\"fallback\", \"league\")\n",
    ")\n",
    "fig, ax = plt.subplots(figsize=(9, 6.5))\n",
    "ax.barh(share[\"league\"], share[\"fallback\"], color=[\"#c84630\" if f == 1 else \"#4a6fa5\" for f in share[\"fallback\"]])\n",
    "ax.xaxis.set_major_formatter(lambda v, _: f\"{v:.0%}\")\n",
    "ax.set_xlabel(\"Share of the league's teams with fallback colors\")\n",
    "ax.tick_params(axis=\"y\", labelsize=8)\n",
    "ax.spines[[\"top\", \"right\"]].set_visible(False)\n",
    "ax.set_title(\"Where sdvplot's colors are stand-ins\", loc=\"left\", fontweight=\"bold\")\n",
    "fig.text(0.99, 0.01, f\"sdvplot {sdvplot.__version__} team index\", ha=\"right\", fontsize=8, color=\"grey\")\n",
    "plt.show()\n",
    "\n",
    "city = sdvplot.teams(\"soccer\").filter(pl.col(\"team_id\") == \"382\")  # ESPN's id for Manchester City\n",
    "print(city.select(\"name\", \"color_primary\", \"color_source\").row(0))\n",
    "print(sdvplot.palette(\"soccer\", teams=[\"382\"]) | {\"382\": \"#6CABDD\"})  # your own color wins"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "sdvplot": {
   "description": "Nine color recipes: league palettes, colors per row, readable text on team fills, seaborn, clashing colors, a league-wide colormap, PyPalettes and morethemes, and fallback colors.",
   "label": "Colors and themes",
   "position": 5
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
