{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "0",
   "metadata": {},
   "source": [
    "# Interactive charts\n",
    "\n",
    "Eleven recipes for team logos on web charts: Plotly, Altair, Bokeh and HoloViews scatters and bars with hover\n",
    "details, logos on axes where the library allows it, a Folium map of team locations, self-contained HTML, and\n",
    "static PNG exports for social posts. Every adapter shares the `add_logos(target, x, y, teams, league=...)`\n",
    "call. The data is one season each from the NFL (nflverse), MLB (ESPN), the WNBA and NBA (wehoop and hoopR),\n",
    "the NHL (fastRhockey), and men's college basketball and college football (hoopR and cfbfastR), all through\n",
    "sportsdataverse-py."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1",
   "metadata": {},
   "outputs": [],
   "source": [
    "import tempfile\n",
    "from pathlib import Path\n",
    "\n",
    "import altair as alt\n",
    "import folium\n",
    "import holoviews as hv\n",
    "import plotly.graph_objects as go\n",
    "import polars as pl\n",
    "import sportsdataverse.cfb as cfb\n",
    "import sportsdataverse.mbb as mbb\n",
    "import sportsdataverse.mlb as mlb\n",
    "import sportsdataverse.nba as nba\n",
    "import sportsdataverse.nfl as nfl\n",
    "import sportsdataverse.nhl as nhl\n",
    "import sportsdataverse.wnba as wnba\n",
    "from bokeh.io import output_notebook, show\n",
    "from bokeh.models import ColumnDataSource, HoverTool\n",
    "from bokeh.plotting import figure\n",
    "from IPython.display import Image, display\n",
    "\n",
    "import sdvplot\n",
    "\n",
    "output_notebook(hide_banner=True)\n",
    "hv.extension(\"bokeh\", logo=False)\n",
    "NFL_SEASON = CFB_SEASON = 2025  # football names a season by the year it starts\n",
    "SEASON = 2026  # the 2026 MLB and WNBA seasons, and the 2025-26 NBA, NHL and college basketball season"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2",
   "metadata": {},
   "source": [
    "`output_notebook` and `hv.extension` load BokehJS once for the whole notebook. The shared tables, each one\n",
    "small: NFL EPA per play, MLB final standings from ESPN, WNBA and NHL scoring per game (more than ten games\n",
    "drops the WNBA All-Star Game's teams), NBA three-point attempts and the Big 12's adjusted efficiency."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3",
   "metadata": {},
   "outputs": [],
   "source": [
    "nfl_weeks = nfl.load_nfl_team_stats([NFL_SEASON]).filter(pl.col(\"season_type\") == \"REG\")\n",
    "plays = pl.col(\"attempts\") + pl.col(\"sacks_suffered\") + pl.col(\"carries\")\n",
    "epa = pl.col(\"passing_epa\") + pl.col(\"rushing_epa\")\n",
    "nfl_epa = (\n",
    "    nfl_weeks.group_by(\"team\", maintain_order=True)\n",
    "    .agg(off_epa=epa.sum() / plays.sum())\n",
    "    .join(\n",
    "        nfl_weeks.group_by(team=pl.col(\"opponent_team\"), maintain_order=True).agg(def_epa=epa.sum() / plays.sum()),\n",
    "        on=\"team\",\n",
    "    )\n",
    "    .sort(\"team\")\n",
    ")\n",
    "\n",
    "mlb_standings = (\n",
    "    mlb.espn_mlb_standings(season=SEASON)\n",
    "    .with_columns(\n",
    "        rs=pl.col(\"points_for\") / pl.col(\"games_played\"), ra=pl.col(\"points_against\") / pl.col(\"games_played\")\n",
    "    )\n",
    "    .sort(\"team_abbreviation\")\n",
    ")\n",
    "\n",
    "wnba_teams = (\n",
    "    wnba.load_wnba_team_boxscore(seasons=[SEASON])\n",
    "    .filter(pl.col(\"season_type\") == 2)\n",
    "    .group_by(\"team_abbreviation\", maintain_order=True)\n",
    "    .agg(games=pl.len(), scored=pl.col(\"team_score\").mean(), allowed=pl.col(\"opponent_team_score\").mean())\n",
    "    .filter(pl.col(\"games\") > 10)\n",
    "    .sort(\"team_abbreviation\")\n",
    ")\n",
    "\n",
    "nhl_teams = (\n",
    "    nhl.load_nhl_team_box(seasons=[SEASON])\n",
    "    .filter(pl.col(\"game_id\") // 10_000 % 100 == 2)\n",
    "    .group_by(\"team_abbrev\", maintain_order=True)\n",
    "    .agg(gf=pl.col(\"goals\").mean(), ga=pl.col(\"goals_against\").mean(), sv=pl.col(\"save_pctg\").mean())\n",
    "    .sort(\"team_abbrev\")\n",
    ")\n",
    "\n",
    "nba_threes = (\n",
    "    nba.load_nba_team_boxscore(seasons=[SEASON])\n",
    "    .filter(pl.col(\"season_type\") == 2)\n",
    "    .group_by(\"team_id\", \"team_abbreviation\", maintain_order=True)\n",
    "    .agg(\n",
    "        games=pl.len(),\n",
    "        fg3a=pl.col(\"three_point_field_goals_attempted\").mean(),\n",
    "        fg3_pct=pl.col(\"three_point_field_goals_made\").sum() / pl.col(\"three_point_field_goals_attempted\").sum(),\n",
    "    )\n",
    "    .filter(pl.col(\"games\") > 10)\n",
    "    .with_columns(pl.col(\"team_id\").cast(pl.Int64).cast(pl.Utf8))\n",
    "    .sort(\"team_abbreviation\")\n",
    ")\n",
    "\n",
    "big12 = (\n",
    "    mbb.load_mbb_ratings(SEASON)\n",
    "    .join(\n",
    "        sdvplot.teams(\"mbb\").filter(pl.col(\"conference\") == \"Big 12 Conference\").select(\"team_id\", \"name\"),\n",
    "        on=\"team_id\",\n",
    "    )\n",
    "    .sort(\"team_id\")\n",
    ")\n",
    "mlb_standings[\"games_played\"].max(), nfl_epa.height, wnba_teams.height, nhl_teams.height, big12.height"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4",
   "metadata": {},
   "source": [
    "## 1. Plotly: logos as markers, with hover details\n",
    "\n",
    "Plotly draws the logos as layout images, which have no hover, so put the hover text on a transparent marker\n",
    "trace at the same points. Call `add_logos` after the traces: it works out the axis ranges (with room for the\n",
    "logos) and pins them, and the logos then zoom with the data. A reversed range set before the call is kept."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5",
   "metadata": {},
   "outputs": [],
   "source": [
    "fig = go.Figure(\n",
    "    go.Scatter(\n",
    "        x=nfl_epa[\"off_epa\"],\n",
    "        y=nfl_epa[\"def_epa\"],\n",
    "        mode=\"markers\",\n",
    "        marker={\"size\": 30, \"opacity\": 0},\n",
    "        customdata=nfl_epa[\"team\"],\n",
    "        hovertemplate=\"%{customdata}<br>Offense %{x:+.3f} EPA/play<br>Defense %{y:+.3f}<extra></extra>\",\n",
    "    )\n",
    ")\n",
    "pad = 0.03\n",
    "fig.update_yaxes(range=[nfl_epa[\"def_epa\"].max() + pad, nfl_epa[\"def_epa\"].min() - pad])  # good defense up\n",
    "fig.update_xaxes(range=[nfl_epa[\"off_epa\"].min() - pad, nfl_epa[\"off_epa\"].max() + pad])\n",
    "sdvplot.add_logos(fig, nfl_epa[\"off_epa\"], nfl_epa[\"def_epa\"], nfl_epa[\"team\"], league=\"nfl\", height=0.08)\n",
    "fig.update_layout(\n",
    "    title=f\"NFL offense vs defense, {NFL_SEASON}<br><sup>Data: nflverse via sportsdataverse-py</sup>\",\n",
    "    xaxis_title=\"Offense: EPA per play\",\n",
    "    yaxis_title=\"Defense: EPA per play allowed\",\n",
    "    template=\"plotly_white\",\n",
    "    width=850,\n",
    "    height=560,\n",
    ")\n",
    "fig"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6",
   "metadata": {},
   "source": [
    "## 2. Plotly: logos on a category axis\n",
    "\n",
    "`axis_logos` replaces the category labels with logos under the axis and grows the margin to fit. ESPN's\n",
    "abbreviations (`ATH`, `CHW`, `WSH`) resolve as they are. Every MLB team's run differential:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7",
   "metadata": {},
   "outputs": [],
   "source": [
    "ranked = mlb_standings.sort([\"point_differential\", \"team_abbreviation\"], descending=[True, False])\n",
    "fig = go.Figure(\n",
    "    go.Bar(\n",
    "        x=ranked[\"team_abbreviation\"],\n",
    "        y=ranked[\"point_differential\"],\n",
    "        marker_color=sdvplot.team_colors(ranked[\"team_abbreviation\"], \"mlb\"),\n",
    "        customdata=ranked[\"team_display_name\"],\n",
    "        hovertemplate=\"%{customdata}<br>Run differential %{y:+d}<extra></extra>\",\n",
    "    )\n",
    ")\n",
    "sdvplot.axis_logos(fig, \"x\", league=\"mlb\", height=0.05)\n",
    "fig.update_layout(\n",
    "    title=f\"MLB run differential, {SEASON} regular season<br><sup>Data: ESPN via sportsdataverse-py</sup>\",\n",
    "    yaxis_title=\"Runs scored minus runs allowed\",\n",
    "    template=\"plotly_white\",\n",
    "    width=900,\n",
    "    height=480,\n",
    ")\n",
    "fig"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8",
   "metadata": {},
   "source": [
    "## 3. Altair: logos with tooltips\n",
    "\n",
    "`add_logos` returns a new layered chart: the base chart plus an image layer that reuses its encodings. Put the\n",
    "tooltip on the base marks (nearly transparent, so they still catch the pointer). Reverse the y scale in the\n",
    "base chart; the logo layer follows it."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9",
   "metadata": {},
   "outputs": [],
   "source": [
    "base = (\n",
    "    alt.Chart(wnba_teams)\n",
    "    .mark_circle(size=900, opacity=0.01)\n",
    "    .encode(\n",
    "        x=alt.X(\"scored:Q\", scale=alt.Scale(zero=False, padding=30), title=\"Points scored per game\"),\n",
    "        y=alt.Y(\"allowed:Q\", scale=alt.Scale(zero=False, reverse=True, padding=30), title=\"Points allowed per game\"),\n",
    "        tooltip=[\n",
    "            alt.Tooltip(\"team_abbreviation:N\", title=\"Team\"),\n",
    "            alt.Tooltip(\"scored:Q\", format=\".1f\"),\n",
    "            alt.Tooltip(\"allowed:Q\", format=\".1f\"),\n",
    "        ],\n",
    "    )\n",
    "    .properties(width=620, height=420)\n",
    ")\n",
    "chart = sdvplot.add_logos(\n",
    "    base, wnba_teams[\"scored\"], wnba_teams[\"allowed\"], wnba_teams[\"team_abbreviation\"], league=\"wnba\", height=0.1\n",
    ")\n",
    "chart.properties(title=alt.Title(f\"WNBA scoring, {SEASON}\", subtitle=\"Data: wehoop (ESPN) via sportsdataverse-py\"))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "10",
   "metadata": {},
   "source": [
    "## 4. Altair: logos on a discrete axis\n",
    "\n",
    "`axis_logos` blanks the axis labels that became logos and draws the logos as a layer just outside the plot.\n",
    "Keep the data's order with `sort=None`. The Western Conference's three-point volume, with accuracy in the\n",
    "tooltip:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "11",
   "metadata": {},
   "outputs": [],
   "source": [
    "west_ids = sdvplot.teams(\"nba\").filter(pl.col(\"conference\") == \"Western Conference\").select(\"team_id\")\n",
    "west = nba_threes.join(west_ids, on=\"team_id\").sort([\"fg3a\", \"team_abbreviation\"], descending=[True, False])\n",
    "colors = sdvplot.palette(\"nba\", teams=west[\"team_abbreviation\"])\n",
    "bars = (\n",
    "    alt.Chart(west)\n",
    "    .mark_bar()\n",
    "    .encode(\n",
    "        x=alt.X(\"team_abbreviation:N\", sort=None, title=None),\n",
    "        y=alt.Y(\"fg3a:Q\", title=\"Three-point attempts per game\"),\n",
    "        color=alt.Color(\n",
    "            \"team_abbreviation:N\", scale=alt.Scale(domain=list(colors), range=list(colors.values())), legend=None\n",
    "        ),  # fmt: skip\n",
    "        tooltip=[alt.Tooltip(\"fg3a:Q\", format=\".1f\"), alt.Tooltip(\"fg3_pct:Q\", format=\".1%\", title=\"3P%\")],\n",
    "    )\n",
    "    .properties(width=640, height=320, title=\"Western Conference three-point volume, 2025-26\")\n",
    ")\n",
    "sdvplot.axis_logos(bars, \"x\", league=\"nba\", height=0.09)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "12",
   "metadata": {},
   "source": [
    "## 5. Bokeh: logos with a hover tool\n",
    "\n",
    "Bokeh sizes logos in screen pixels, as a fraction of `frame_height`, so they stay the same size when you zoom.\n",
    "A transparent scatter renderer carries the `HoverTool`. NHL goals for and against per game:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "13",
   "metadata": {},
   "outputs": [],
   "source": [
    "source = ColumnDataSource(nhl_teams.to_pandas())\n",
    "p = figure(\n",
    "    frame_width=620,\n",
    "    frame_height=420,\n",
    "    title=\"NHL goals for vs against per game, 2025-26 (data: fastRhockey via sportsdataverse-py)\",\n",
    "    x_axis_label=\"Goals for per game\",\n",
    "    y_axis_label=\"Goals against per game (reversed)\",\n",
    ")\n",
    "dots = p.scatter(\"gf\", \"ga\", source=source, size=28, alpha=0)\n",
    "p.add_tools(\n",
    "    HoverTool(\n",
    "        renderers=[dots],\n",
    "        tooltips=[(\"Team\", \"@team_abbrev\"), (\"For\", \"@gf{0.00}\"), (\"Against\", \"@ga{0.00}\"), (\"SV%\", \"@sv{0.000}\")],\n",
    "    )\n",
    ")\n",
    "p.y_range.flipped = True\n",
    "p.x_range.range_padding = p.y_range.range_padding = 0.15  # room for the logos at the edges\n",
    "sdvplot.add_logos(p, nhl_teams[\"gf\"], nhl_teams[\"ga\"], nhl_teams[\"team_abbrev\"], league=\"nhl\", height=0.07)\n",
    "show(p)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "14",
   "metadata": {},
   "source": [
    "## 6. Bokeh has no axis logos: put them inside the plot\n",
    "\n",
    "Bokeh glyphs cannot sit outside the plot frame, so `axis_logos` raises on Bokeh (and HoloViews) with a\n",
    "`TypeError` that says what to do instead: draw the logos with `add_logos` at a y just below the bars."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "15",
   "metadata": {},
   "outputs": [],
   "source": [
    "top = nhl_teams.sort([\"gf\", \"team_abbrev\"], descending=[True, False]).head(12)\n",
    "teams = top[\"team_abbrev\"].to_list()\n",
    "p = figure(x_range=teams, frame_width=700, frame_height=360, title=\"NHL goals per game, 2025-26 (top 12)\")\n",
    "p.vbar(x=teams, top=top[\"gf\"].to_list(), width=0.7, color=sdvplot.team_colors(teams, \"nhl\"))\n",
    "try:\n",
    "    sdvplot.axis_logos(p, \"x\", league=\"nhl\")\n",
    "except TypeError as e:\n",
    "    print(e)\n",
    "p.y_range.start = -0.45\n",
    "sdvplot.add_logos(p, teams, [-0.22] * len(teams), teams, league=\"nhl\", height=0.1)\n",
    "p.xaxis.major_label_text_font_size = \"0pt\"  # the logos are the labels now\n",
    "p.xgrid.grid_line_color = None\n",
    "show(p)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "16",
   "metadata": {},
   "source": [
    "## 7. HoloViews: logos on an element\n",
    "\n",
    "On HoloViews (Bokeh backend) `add_logos` returns a copy of the element with a plot hook that draws the logos\n",
    "when it renders; give the element a `frame_height` so the logos have a size to scale from. The Big 12:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "17",
   "metadata": {},
   "outputs": [],
   "source": [
    "points = hv.Scatter(big12.to_pandas(), \"adj_o\", [\"adj_d\", \"name\", \"adj_em\"]).opts(\n",
    "    frame_width=600,\n",
    "    frame_height=420,\n",
    "    size=28,\n",
    "    alpha=0,\n",
    "    tools=[\"hover\"],\n",
    "    invert_yaxis=True,\n",
    "    xlabel=\"Adjusted offense (points per 100)\",\n",
    "    ylabel=\"Adjusted defense (points allowed per 100)\",\n",
    "    title=\"Big 12 adjusted efficiency, 2025-26 (data: hoopR via sportsdataverse-py)\",\n",
    ")\n",
    "sdvplot.add_logos(points, big12[\"adj_o\"], big12[\"adj_d\"], big12[\"team_id\"], league=\"mbb\", height=0.08)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "18",
   "metadata": {},
   "source": [
    "## 8. Folium: a map of team locations\n",
    "\n",
    "On a Folium map, x is longitude and y is latitude; each logo is a marker with the team's name as its tooltip.\n",
    "cfbfastR's team info carries every stadium's coordinates, and its ESPN team ids resolve once cast from the\n",
    "integer column to strings. The SEC:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "19",
   "metadata": {},
   "outputs": [],
   "source": [
    "sec = cfb.load_cfb_team_info([CFB_SEASON]).filter(pl.col(\"conference\") == \"SEC\")\n",
    "m = folium.Map(location=[33.3, -88.5], zoom_start=5, height=520)\n",
    "sdvplot.add_logos(\n",
    "    m, sec[\"longitude\"], sec[\"latitude\"], sec[\"team_id\"].cast(pl.Utf8), league=\"cfb\", season=CFB_SEASON, height=0.08\n",
    ")\n",
    "m"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "20",
   "metadata": {},
   "source": [
    "## 9. Share a chart that works offline\n",
    "\n",
    "By default the web adapters link each logo by URL, which keeps the HTML small but needs the network when it is\n",
    "opened. `embed=True` inlines every image as a data URI: a larger file that renders anywhere, including in\n",
    "static exports."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "21",
   "metadata": {},
   "outputs": [],
   "source": [
    "out = Path(tempfile.mkdtemp())\n",
    "for embed in (False, True):\n",
    "    fig = go.Figure(go.Scatter(x=mlb_standings[\"rs\"], y=mlb_standings[\"ra\"], mode=\"markers\", marker={\"opacity\": 0}))\n",
    "    sdvplot.add_logos(\n",
    "        fig, mlb_standings[\"rs\"], mlb_standings[\"ra\"], mlb_standings[\"team_abbreviation\"], league=\"mlb\",\n",
    "        height=0.08, embed=embed,\n",
    "    )  # fmt: skip\n",
    "    path = out / f\"mlb_embed_{embed}.html\"\n",
    "    fig.write_html(path, include_plotlyjs=\"cdn\")\n",
    "    print(f\"embed={embed}: {path.stat().st_size / 1024:,.0f} KB\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "22",
   "metadata": {},
   "source": [
    "## 10. Export a Plotly chart as a PNG for social\n",
    "\n",
    "`fig.write_image` renders through kaleido (and a headless Chrome). Build the figure with `embed=True` so the\n",
    "renderer does not have to fetch each logo, and set the canvas to a social size: 1200 x 675 px here."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "23",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Plotly scatter of MLB runs scored and allowed per game with team logos, exported at 1200 by 675 pixels",
     "title": "MLB runs scored vs allowed, a Plotly chart exported to PNG"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "fig = go.Figure(go.Scatter(x=mlb_standings[\"rs\"], y=mlb_standings[\"ra\"], mode=\"markers\", marker={\"opacity\": 0}))\n",
    "pad = 0.25\n",
    "fig.update_xaxes(range=[mlb_standings[\"rs\"].min() - pad, mlb_standings[\"rs\"].max() + pad])\n",
    "fig.update_yaxes(range=[mlb_standings[\"ra\"].max() + pad, mlb_standings[\"ra\"].min() - pad])  # fewer allowed is up\n",
    "sdvplot.add_logos(\n",
    "    fig, mlb_standings[\"rs\"], mlb_standings[\"ra\"], mlb_standings[\"team_abbreviation\"], league=\"mlb\",\n",
    "    height=0.085, embed=True,\n",
    ")  # fmt: skip\n",
    "fig.update_layout(\n",
    "    title=f\"<b>MLB runs scored vs allowed per game, {SEASON}</b><br><sup>Data: ESPN via sportsdataverse-py</sup>\",\n",
    "    xaxis_title=\"Runs scored per game\",\n",
    "    yaxis_title=\"Runs allowed per game\",\n",
    "    template=\"plotly_white\",\n",
    "    margin={\"l\": 70, \"r\": 30, \"t\": 80, \"b\": 60},\n",
    ")\n",
    "png = out / \"mlb_runs.png\"\n",
    "fig.write_image(png, width=1200, height=675)\n",
    "display(Image(filename=png, width=800))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "24",
   "metadata": {},
   "source": [
    "## 11. Export an Altair chart as a PNG\n",
    "\n",
    "`chart.save(\"x.png\")` renders through vl-convert, no browser needed. The image layer must carry the pictures\n",
    "themselves, so again use `embed=True`; `scale_factor` sets the pixel density."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "25",
   "metadata": {
    "sdvplot_gallery": {
     "alt": "Altair scatter of NHL goals for and against per game with team logos, exported with vl-convert",
     "title": "NHL goals for vs against, an Altair chart exported to PNG"
    },
    "tags": [
     "gallery"
    ]
   },
   "outputs": [],
   "source": [
    "base = (\n",
    "    alt.Chart(nhl_teams)\n",
    "    .mark_circle(opacity=0)\n",
    "    .encode(\n",
    "        x=alt.X(\"gf:Q\", scale=alt.Scale(zero=False, padding=30), title=\"Goals for per game\"),\n",
    "        y=alt.Y(\"ga:Q\", scale=alt.Scale(zero=False, reverse=True, padding=30), title=\"Goals against per game\"),\n",
    "    )\n",
    "    .properties(width=560, height=380)\n",
    ")\n",
    "chart = sdvplot.add_logos(\n",
    "    base, nhl_teams[\"gf\"], nhl_teams[\"ga\"], nhl_teams[\"team_abbrev\"], league=\"nhl\", height=0.08, embed=True\n",
    ").properties(title=alt.Title(\"NHL goals for vs against, 2025-26\", subtitle=\"Data: fastRhockey via sportsdataverse-py\"))\n",
    "png = out / \"nhl_goals.png\"\n",
    "chart.save(png, scale_factor=2)\n",
    "display(Image(filename=png, width=700))"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "sdvplot": {
   "description": "Eleven recipes for Plotly, Altair, Bokeh, HoloViews and Folium: logos with hover details, logo axes, a map of team locations, self-contained HTML and PNG exports for social.",
   "label": "Interactive charts",
   "position": 3
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
