sdvplot 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. sdvplot/__init__.py +68 -0
  2. sdvplot/_cache.py +484 -0
  3. sdvplot/_colors.py +206 -0
  4. sdvplot/_contrast.py +60 -0
  5. sdvplot/_court.py +110 -0
  6. sdvplot/_deprecate.py +39 -0
  7. sdvplot/_dispatch.py +342 -0
  8. sdvplot/_errors.py +93 -0
  9. sdvplot/_headshots.py +131 -0
  10. sdvplot/_images.py +292 -0
  11. sdvplot/_index.py +178 -0
  12. sdvplot/_lazy.py +15 -0
  13. sdvplot/_manifest.py +73 -0
  14. sdvplot/_marks.py +296 -0
  15. sdvplot/_normalize.py +82 -0
  16. sdvplot/_placement.py +168 -0
  17. sdvplot/_resolve.py +363 -0
  18. sdvplot/_surface.py +220 -0
  19. sdvplot/_tables.py +87 -0
  20. sdvplot/_tiers.py +135 -0
  21. sdvplot/_types.py +38 -0
  22. sdvplot/_versions.py +30 -0
  23. sdvplot/_web.py +61 -0
  24. sdvplot/altair.py +652 -0
  25. sdvplot/bokeh.py +323 -0
  26. sdvplot/data/INDEX_VERSION +1 -0
  27. sdvplot/data/aliases.parquet +0 -0
  28. sdvplot/data/teams.parquet +0 -0
  29. sdvplot/folium.py +316 -0
  30. sdvplot/great_tables/__init__.py +267 -0
  31. sdvplot/great_tables/_cells.py +1814 -0
  32. sdvplot/great_tables/_export.py +844 -0
  33. sdvplot/great_tables/_layout.py +1802 -0
  34. sdvplot/great_tables/_marks.py +798 -0
  35. sdvplot/great_tables/_themes.py +2134 -0
  36. sdvplot/holoviews.py +303 -0
  37. sdvplot/matplotlib.py +934 -0
  38. sdvplot/plotly.py +595 -0
  39. sdvplot/plotnine.py +1104 -0
  40. sdvplot/plottable.py +141 -0
  41. sdvplot/py.typed +0 -0
  42. sdvplot/pygal.py +416 -0
  43. sdvplot/reactable.py +488 -0
  44. sdvplot/testing.py +579 -0
  45. sdvplot/typing.py +21 -0
  46. sdvplot-0.1.0.dist-info/METADATA +188 -0
  47. sdvplot-0.1.0.dist-info/RECORD +49 -0
  48. sdvplot-0.1.0.dist-info/WHEEL +4 -0
  49. sdvplot-0.1.0.dist-info/licenses/LICENSE +21 -0
sdvplot/plotnine.py ADDED
@@ -0,0 +1,1104 @@
1
+ """The plotnine adapter: logo, wordmark, headshot and image geoms, axis logos, team color scales and reference lines.
2
+
3
+ The geoms draw through the matplotlib adapter's drawing step, so sizing matches it: ``height``
4
+ is a fraction of each panel's height. ``add_logos(p, ...)`` returns a new ggplot (plotnine's ``+`` copies).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Literal
10
+
11
+ from sdvplot._errors import UnsupportedTargetError, requires_extra, warn
12
+
13
+ with requires_extra("plotnine"):
14
+ import numpy as np
15
+ import pandas as pd
16
+ from matplotlib.figure import Figure
17
+ from plotnine import (
18
+ aes,
19
+ element_blank,
20
+ element_rect,
21
+ element_text,
22
+ geom_text,
23
+ ggplot,
24
+ labs,
25
+ scale_color_manual,
26
+ scale_fill_manual,
27
+ scale_x_continuous,
28
+ scale_y_reverse,
29
+ theme_minimal,
30
+ )
31
+ from plotnine import theme as p9_theme # team_tiers takes a `theme` argument
32
+ from plotnine._utils import remove_missing
33
+ from plotnine.geoms import geom_hline, geom_vline
34
+ from plotnine.geoms.geom import geom
35
+
36
+ from sdvplot import _tiers
37
+ from sdvplot._colors import _column, team_colors
38
+ from sdvplot._marks import _check_mark_type
39
+ from sdvplot._placement import Placement, _warn_skipped, check_alpha, check_height, place, place_images
40
+ from sdvplot._resolve import _seasons, _unpack
41
+ from sdvplot._types import IdSystem, Which
42
+ from sdvplot.matplotlib import (
43
+ _add_title_image,
44
+ _align,
45
+ _check_title_image,
46
+ _draw_images,
47
+ _draw_placements,
48
+ _in_view,
49
+ _read_images,
50
+ _title_source,
51
+ )
52
+ from sdvplot.matplotlib import _axis_logos as _mpl_axis_logos
53
+ from sdvplot.matplotlib import _drawn_axis_marks as _mpl_drawn_axis_marks
54
+ from sdvplot.matplotlib import _drawn_marks as _mpl_drawn_marks
55
+ from sdvplot.matplotlib import _visible_axis_labels as _mpl_visible_axis_labels
56
+
57
+ _SUPPORTS_AXIS_LOGOS = True
58
+ _MARK_PARAMS = {
59
+ "stat": "identity",
60
+ "position": "identity",
61
+ "na_rm": False,
62
+ "league": None,
63
+ "season": None,
64
+ "height": 0.1,
65
+ "alpha": 1,
66
+ "variant": "default",
67
+ "id_system": "auto",
68
+ }
69
+
70
+ __all__ = [
71
+ "add_headshots",
72
+ "add_logos",
73
+ "add_wordmarks",
74
+ "axis_logos",
75
+ "geom_from_path",
76
+ "geom_mean_lines",
77
+ "geom_median_lines",
78
+ "geom_sdv_headshots",
79
+ "geom_sdv_logos",
80
+ "geom_sdv_wordmarks",
81
+ "scale_color_sdv",
82
+ "scale_fill_sdv",
83
+ "team_tiers",
84
+ "title_image",
85
+ ]
86
+
87
+
88
+ def __dir__() -> list[str]: # dir() and tab completion show the public API only
89
+ return list(__all__)
90
+
91
+
92
+ class _geom_sdv_marks(geom):
93
+ """Shared drawing for the image geoms; subclasses set the kind and the id aesthetic."""
94
+
95
+ _kind = "logo"
96
+ _id_aes = "team"
97
+ _needs_league = True
98
+ DEFAULT_AES: dict[str, Any] = {"season": None} # optional: one season per row, which plotnine copies with the row
99
+ REQUIRED_AES = {"x", "y", "team"}
100
+ DEFAULT_PARAMS = _MARK_PARAMS
101
+
102
+ def __init__(self, mapping: Any = None, data: Any = None, **kwargs: Any) -> None:
103
+ if self._needs_league and kwargs.get("league") is None:
104
+ raise TypeError(f"{type(self).__name__}() needs league=, e.g. league='nfl'")
105
+ check_height(kwargs.get("height", 0.1))
106
+ check_alpha(kwargs.get("alpha", 1))
107
+ kwargs.setdefault("show_legend", False)
108
+ super().__init__(mapping, data, **kwargs)
109
+
110
+ def _place(self, data: pd.DataFrame, x: list[Any], y: list[Any], *, warn: bool) -> list[Placement]:
111
+ p = self.params
112
+ # the season= parameter, else the season aesthetic (a column, so each panel's copy of a row keeps its season)
113
+ season = p["season"] if p["season"] is not None or "season" not in data else data["season"].tolist()
114
+ return place(x, y, data[self._id_aes].tolist(), league=p["league"], season=season, kind=self._kind,
115
+ variant=p["variant"], id_system=p["id_system"], _warn=warn) # fmt: skip
116
+
117
+ def setup_data(self, data: pd.DataFrame) -> pd.DataFrame:
118
+ """Once per layer and render: place every panel's rows together, so an unknown team or a missing mark warns
119
+ once, and a point plotnine copies into every panel counts once. At zero positions: x and y are plotnine's to
120
+ check, after this (``na_rm``, scale limits, its own "Removed rows" warning), so they never warn here."""
121
+ # ponytail: two identical points (same x, y and team) in one panel also count once
122
+ rows = data.drop(columns="PANEL", errors="ignore").drop_duplicates() # a row plotnine copies to every panel
123
+ zeros = [0.0] * len(rows)
124
+ self._place(rows, zeros, zeros, warn=True)
125
+ return data
126
+
127
+ def draw_panel(self, data: pd.DataFrame, panel_params: Any, coord: Any, ax: Any) -> None:
128
+ # setup_data already warned for this layer's rows in every panel, so each panel places its rows quietly
129
+ data = coord.transform(data, panel_params)
130
+ placements = self._place(data, data["x"].tolist(), data["y"].tolist(), warn=False)
131
+ _draw_placements(ax, placements, height=float(self.params["height"]), alpha=float(self.params["alpha"]))
132
+
133
+
134
+ class geom_sdv_logos(_geom_sdv_marks):
135
+ """Team logos at (x, y), as a plotnine layer.
136
+
137
+ For a season per row, map it instead of passing ``season=``: ``aes(..., season="season")`` (a ``season=``
138
+ parameter wins over the mapping).
139
+
140
+ Args:
141
+ mapping: ``aes(x=..., y=..., team=...)``, plus optional ``season``.
142
+ data: The layer's data (pandas or polars), when not the plot's.
143
+ **kwargs: ``league`` (required, e.g. "nfl"), ``season``, ``height`` (a fraction of the panel height, in (0, 1],
144
+ default 0.1), ``alpha`` (0 to 1), ``variant`` ("default", "dark" or a named variant), ``id_system`` and
145
+ plotnine's layer arguments (``inherit_aes``, ...).
146
+
147
+ Returns:
148
+ geom: A plotnine layer to add with ``+``. Marks that cannot be placed (an unknown team, a missing mark) are
149
+ skipped with one SdvplotWarning when the plot is drawn.
150
+
151
+ Raises:
152
+ TypeError: If ``league`` is missing.
153
+ InputError: (a ValueError) If ``height`` or ``alpha`` is out of range (when the layer is built); when the plot
154
+ is drawn, if ``league``, ``id_system`` or ``variant`` is unknown, or a season is not a year or is outside
155
+ the seasons sdvplot knows for the league.
156
+ OfflineError: When the plot is drawn, if the logo manifest or a mark's image is neither cached nor downloadable
157
+ (a DownloadError, also an OSError, when the CDN answers with an error status; an IntegrityError when it
158
+ sends a file that does not match the manifest's sha256, or one PIL cannot decode).
159
+ UnsafeDownloadError: (an OSError) When the plot is drawn, if a download is refused: larger than the byte cap,
160
+ past the deadline, or redirected away from https.
161
+ UnsafeCachePathError: (a ValueError) When the plot is drawn, if the manifest's sha256 or extension for a mark
162
+ would put the file outside the cache directory.
163
+ OptionalDependencyError: When the plot is drawn, if a mark is an SVG and the ``svg`` extra is not installed.
164
+
165
+ Example:
166
+ ::
167
+
168
+ import pandas as pd
169
+ from plotnine import aes, ggplot
170
+ from sdvplot.plotnine import geom_sdv_logos
171
+
172
+ df = pd.DataFrame({"team": ["KC", "BUF"], "epa": [0.2, 0.15], "sr": [0.48, 0.47]})
173
+ p = ggplot(df, aes("epa", "sr", team="team")) + geom_sdv_logos(league="nfl", height=0.12)
174
+
175
+ See Also:
176
+ sdvplotR geom_nfl_logos(): https://sdvplotR.sportsdataverse.org/ ;
177
+ sdvplot.plotnine.geom_sdv_wordmarks: the same with wordmarks
178
+ """
179
+
180
+
181
+ class geom_sdv_wordmarks(_geom_sdv_marks):
182
+ """Team wordmarks at (x, y), as a plotnine layer: the same aesthetics and parameters as ``geom_sdv_logos``.
183
+
184
+ Args:
185
+ mapping: ``aes(x=..., y=..., team=...)``, plus optional ``season``.
186
+ data: The layer's data (pandas or polars), when not the plot's.
187
+ **kwargs: ``league`` (required, e.g. "nfl"), ``season``, ``height`` (a fraction of the panel height, in (0, 1],
188
+ default 0.1), ``alpha`` (0 to 1), ``variant`` ("default", "dark" or a named variant), ``id_system`` and
189
+ plotnine's layer arguments (``inherit_aes``, ...).
190
+
191
+ Returns:
192
+ geom: A plotnine layer to add with ``+``. Marks that cannot be placed (an unknown team, a missing mark) are
193
+ skipped with one SdvplotWarning when the plot is drawn.
194
+
195
+ Raises:
196
+ TypeError: If ``league`` is missing.
197
+ InputError: (a ValueError) If ``height`` or ``alpha`` is out of range (when the layer is built); when the plot
198
+ is drawn, if ``league``, ``id_system`` or ``variant`` is unknown, or a season is not a year or is outside
199
+ the seasons sdvplot knows for the league.
200
+ OfflineError: When the plot is drawn, if the logo manifest or a mark's image is neither cached nor downloadable
201
+ (a DownloadError, also an OSError, when the CDN answers with an error status; an IntegrityError when it
202
+ sends a file that does not match the manifest's sha256, or one PIL cannot decode).
203
+ UnsafeDownloadError: (an OSError) When the plot is drawn, if a download is refused: larger than the byte cap,
204
+ past the deadline, or redirected away from https.
205
+ UnsafeCachePathError: (a ValueError) When the plot is drawn, if the manifest's sha256 or extension for a mark
206
+ would put the file outside the cache directory.
207
+ OptionalDependencyError: When the plot is drawn, if a mark is an SVG and the ``svg`` extra is not installed.
208
+
209
+ Example:
210
+ ::
211
+
212
+ import pandas as pd
213
+ from plotnine import aes, ggplot
214
+ from sdvplot.plotnine import geom_sdv_wordmarks
215
+
216
+ df = pd.DataFrame({"team": ["KC", "BUF"], "epa": [0.2, 0.15], "sr": [0.48, 0.47]})
217
+ p = ggplot(df, aes("epa", "sr", team="team")) + geom_sdv_wordmarks(league="nfl", height=0.08)
218
+
219
+ See Also:
220
+ sdvplotR geom_nfl_wordmarks(): https://sdvplotR.sportsdataverse.org/ ;
221
+ sdvplot.plotnine.geom_sdv_logos: the same with logos
222
+ """
223
+
224
+ _kind = "wordmark"
225
+
226
+
227
+ class geom_sdv_headshots(_geom_sdv_marks):
228
+ """Player headshots at (x, y), as a plotnine layer.
229
+
230
+ Args:
231
+ mapping: ``aes(x=..., y=..., player_id=...)``.
232
+ data: The layer's data (pandas or polars), when not the plot's.
233
+ **kwargs: ``league`` (required, e.g. "nfl"), ``height`` (a fraction of the panel height, in (0, 1],
234
+ default 0.1), ``alpha`` (0 to 1), ``id_system`` and
235
+ plotnine's layer arguments (``inherit_aes``, ...).
236
+
237
+ Returns:
238
+ geom: A plotnine layer to add with ``+``. Marks that cannot be placed (an unknown team, a missing mark) are
239
+ skipped with one SdvplotWarning when the plot is drawn.
240
+
241
+ Raises:
242
+ TypeError: If ``league`` is missing.
243
+ InputError: (a ValueError) If ``height`` or ``alpha`` is out of range (when the layer is built); when the plot
244
+ is drawn, if ``league`` has no ESPN headshots or ``id_system`` is not valid for it.
245
+ OfflineError: When the plot is drawn, if a headshot, or with ``id_system="gsis"`` the nflverse player table, is
246
+ neither cached nor downloadable (a DownloadError, also an OSError, for an HTTP error status).
247
+ UnsafeDownloadError: (an OSError) When the plot is drawn, if a download is refused: larger than the byte cap,
248
+ past the deadline, or redirected away from https.
249
+
250
+ Example:
251
+ ::
252
+
253
+ import pandas as pd
254
+ from plotnine import aes, ggplot
255
+ from sdvplot.plotnine import geom_sdv_headshots
256
+
257
+ df = pd.DataFrame({"x": [0.3, 0.7], "y": [0.4, 0.6], "espn_id": ["3139477", "3918298"]})
258
+ p = ggplot(df, aes("x", "y", player_id="espn_id")) + geom_sdv_headshots(league="nfl", height=0.15)
259
+
260
+ See Also:
261
+ sdvplotR geom_nfl_headshots(): https://sdvplotR.sportsdataverse.org/ ;
262
+ sdvplot.plotnine.geom_sdv_logos: the same with team logos
263
+ """
264
+
265
+ _kind = "headshot"
266
+ _id_aes = "player_id"
267
+ REQUIRED_AES = {"x", "y", "player_id"}
268
+ DEFAULT_PARAMS = {**_MARK_PARAMS, "id_system": "espn"}
269
+
270
+
271
+ class geom_from_path(_geom_sdv_marks):
272
+ """Any image, by local path or URL, at (x, y): ``aes(x=..., y=..., path=...)``, plus ``height`` (fraction of the
273
+ panel height, default 0.1) and ``alpha``. The port of ggpath's ``geom_from_path()``, sized like the logo geoms.
274
+
275
+ Args:
276
+ mapping: ``aes(x=..., y=..., path=...)``; ``path`` holds a local file path, ``file://`` URI or https URL per
277
+ row (http is refused).
278
+ data: The layer's data (pandas or polars), when not the plot's.
279
+ **kwargs: ``height`` in (0, 1], ``alpha`` in [0, 1], and plotnine's layer arguments (``inherit_aes``, ...).
280
+
281
+ Returns:
282
+ geom: A plotnine layer to add with ``+``. Images that cannot be read (a missing file, a file that is not an
283
+ image, a failed download) are skipped with one SdvplotWarning when the plot is drawn; SVG is not read.
284
+
285
+ Raises:
286
+ InputError: (a ValueError) If ``height`` or ``alpha`` is out of range (when the layer is built). An image that
287
+ cannot be read or downloaded is skipped with a warning when the plot is drawn, not raised.
288
+
289
+ Example:
290
+ ::
291
+
292
+ import pandas as pd
293
+ from plotnine import aes, ggplot
294
+ from sdvplot.plotnine import geom_from_path
295
+
296
+ df = pd.DataFrame({"x": [1, 2], "y": [1, 2], "img": ["a.png", "https://www.python.org/static/favicon.ico"]})
297
+ p = ggplot(df, aes("x", "y", path="img")) + geom_from_path(height=0.15)
298
+
299
+ See Also:
300
+ ggpath geom_from_path(): https://mrcaseb.github.io/ggpath/ ;
301
+ sdvplot.matplotlib.add_images: the matplotlib counterpart
302
+ """
303
+
304
+ _id_aes = "path"
305
+ _needs_league = False
306
+ DEFAULT_AES: dict[str, Any] = {}
307
+ REQUIRED_AES = {"x", "y", "path"}
308
+ DEFAULT_PARAMS = {"stat": "identity", "position": "identity", "na_rm": False, "height": 0.1, "alpha": 1}
309
+ _sdv_images: dict[str, Any] # path -> image (None when unreadable), read once per render by setup_data
310
+
311
+ def _place(self, data: pd.DataFrame, x: list[Any], y: list[Any], *, warn: bool) -> list[Placement]:
312
+ return place_images(x, y, data["path"].tolist(), _warn=warn)
313
+
314
+ def setup_data(self, data: pd.DataFrame) -> pd.DataFrame:
315
+ """Once per layer and render, as for the team geoms: read every distinct image once, so an image that cannot
316
+ be read (or downloaded) is tried once and warns once, whatever the facets; the panels draw from these."""
317
+ rows = data.drop(columns="PANEL", errors="ignore").drop_duplicates() # a row plotnine copies to every panel
318
+ zeros = [0.0] * len(rows)
319
+ self._sdv_images = {}
320
+ _warn_skipped("whose image could not be read", _read_images(self._place(rows, zeros, zeros, warn=True),
321
+ self._sdv_images)) # fmt: skip
322
+ return data
323
+
324
+ def draw_panel(self, data: pd.DataFrame, panel_params: Any, coord: Any, ax: Any) -> None:
325
+ # setup_data already read the images and warned, so each panel draws its rows quietly
326
+ data = coord.transform(data, panel_params)
327
+ placements = self._place(data, data["x"].tolist(), data["y"].tolist(), warn=False)
328
+ _draw_images(ax, placements, height=float(self.params["height"]), alpha=float(self.params["alpha"]),
329
+ cache=self._sdv_images, warn=False) # fmt: skip
330
+
331
+
332
+ class _geom_ref_lines(geom):
333
+ """A vertical line at ``_ref(x0)`` and a horizontal line at ``_ref(y0)`` per panel, drawn by plotnine's own
334
+ geom_vline / geom_hline (ggpath draws through GeomVline / GeomHline the same way)."""
335
+
336
+ _ref: Any = staticmethod(np.mean)
337
+ # ggpath's GeomRefLines, except alpha: ggplot2's NA (the color's own alpha) is 1 in plotnine, which also keeps
338
+ # an 8-digit hex color's alpha
339
+ DEFAULT_AES = {"color": "red", "size": 0.5, "linetype": "dashed", "alpha": 1}
340
+ REQUIRED_AES: set[str] = set()
341
+ DEFAULT_PARAMS = {"stat": "identity", "position": "identity", "na_rm": False}
342
+
343
+ def __init__(self, mapping: Any = None, data: Any = None, **kwargs: Any) -> None:
344
+ kwargs.setdefault("show_legend", False)
345
+ super().__init__(mapping, data, **kwargs)
346
+
347
+ def draw_layer(self, data: pd.DataFrame, layout: Any, coord: Any) -> None:
348
+ # x0/y0 are position aesthetics in ggplot2: each panel's position scale transforms them (a log scale averages
349
+ # the logs) and censors those outside its limits to NA before the mean. plotnine does not know them, so do
350
+ # both here, panel by panel (free scales differ).
351
+ data = data.copy()
352
+ for ae, which in (("x0", "x"), ("y0", "y")):
353
+ if ae not in data:
354
+ continue
355
+ data[ae] = data[ae].astype(float)
356
+ for panel, rows in data.groupby("PANEL", observed=True).groups.items():
357
+ sc = getattr(layout.get_scales(panel), which)
358
+ data.loc[rows, ae] = sc.map(sc.transform(data.loc[rows, ae].to_numpy()))
359
+ super().draw_layer(data, layout, coord)
360
+
361
+ def draw_panel(self, data: pd.DataFrame, panel_params: Any, coord: Any, ax: Any) -> None:
362
+ name = type(self).__name__
363
+ if "x0" not in data and "y0" not in data:
364
+ raise ValueError(f"{name}() needs an x0 and/or a y0 aesthetic, e.g. aes(x0='epa', y0='success_rate')")
365
+ # ggplot2's GeomHline/GeomVline draw one segment per distinct row of these (ggpath passes them on): one line per
366
+ # panel, or one per group when a colour (or another line aesthetic) is mapped, all at the panel's value
367
+ styles = data[[c for c in ("PANEL", "group", "color", "size", "linetype", "alpha") if c in data]]
368
+ lines: tuple[tuple[str, Any, str], ...] = (("y0", geom_hline, "yintercept"), ("x0", geom_vline, "xintercept"))
369
+ for ae, line, column in lines:
370
+ if ae not in data:
371
+ continue
372
+ values = data[ae].to_numpy(dtype=float)
373
+ if self.params["na_rm"]:
374
+ values = values[~np.isnan(values)]
375
+ ref = float(self._ref(values)) if len(values) else np.nan # NaN when a value is missing, as R's mean()
376
+ # ggplot2 drops (and warns about) a line at NA whatever na.rm says; so does plotnine's remove_missing
377
+ frame = styles.assign(**{column: ref}).drop_duplicates().reset_index(drop=True)
378
+ frame = remove_missing(frame, False, [column], name)
379
+ if len(frame):
380
+ line.draw_panel(self, frame, panel_params, coord, ax)
381
+
382
+
383
+ class geom_mean_lines(_geom_ref_lines):
384
+ """Reference lines at the mean of ``x0`` (vertical) and/or ``y0`` (horizontal), per panel: the port of ggpath's
385
+ ``geom_mean_lines()``.
386
+
387
+ Args:
388
+ mapping: ``aes(x0=..., y0=...)``, at least one of them (``x0`` alone draws only the vertical line).
389
+ data: The layer's data (pandas or polars), when not the plot's.
390
+ **kwargs: ``color`` (default "red"), ``size`` (line width, default 0.5), ``linetype`` (default "dashed"),
391
+ ``alpha``, ``na_rm`` and plotnine's layer arguments. With ``na_rm=False`` (the default) a panel whose
392
+ values include a missing one draws no line on that axis, with a PlotnineWarning, as ggpath does;
393
+ ``na_rm=True`` ignores the missing values.
394
+
395
+ Returns:
396
+ geom: A plotnine layer to add with ``+``; each facet panel gets its own reference value.
397
+
398
+ Raises:
399
+ ValueError: When the plot is drawn, if neither ``x0`` nor ``y0`` is mapped.
400
+
401
+ Example:
402
+ ::
403
+
404
+ import pandas as pd
405
+ from plotnine import aes, geom_point, ggplot
406
+ from sdvplot.plotnine import geom_mean_lines
407
+
408
+ df = pd.DataFrame({"epa": [0.2, 0.15, 0.05], "success_rate": [0.48, 0.47, 0.44]})
409
+ p = (ggplot(df, aes("epa", "success_rate", x0="epa", y0="success_rate"))
410
+ + geom_point() + geom_mean_lines(color="grey"))
411
+
412
+ See Also:
413
+ ggpath geom_mean_lines(): https://mrcaseb.github.io/ggpath/ ;
414
+ geom_median_lines: the same at the median
415
+ """
416
+
417
+
418
+ class geom_median_lines(_geom_ref_lines):
419
+ """Reference lines at the median of ``x0`` (vertical) and/or ``y0`` (horizontal), per panel: the port of ggpath's
420
+ ``geom_median_lines()``.
421
+
422
+ Args:
423
+ mapping: ``aes(x0=..., y0=...)``, at least one of them.
424
+ data: The layer's data (pandas or polars), when not the plot's.
425
+ **kwargs: ``color`` (default "red"), ``size`` (default 0.5), ``linetype`` (default "dashed"), ``alpha``,
426
+ ``na_rm`` (as ``geom_mean_lines``) and plotnine's layer arguments.
427
+
428
+ Returns:
429
+ geom: A plotnine layer to add with ``+``; each facet panel gets its own reference value.
430
+
431
+ Raises:
432
+ ValueError: When the plot is drawn, if neither ``x0`` nor ``y0`` is mapped.
433
+
434
+ Example:
435
+ ::
436
+
437
+ import pandas as pd
438
+ from plotnine import aes, geom_point, ggplot
439
+ from sdvplot.plotnine import geom_median_lines
440
+
441
+ df = pd.DataFrame({"epa": [0.2, 0.15, 0.05], "success_rate": [0.48, 0.47, 0.44]})
442
+ p = ggplot(df, aes("epa", "success_rate", x0="epa", y0="success_rate")) + geom_point() + geom_median_lines()
443
+
444
+ See Also:
445
+ ggpath geom_median_lines(): https://mrcaseb.github.io/ggpath/ ;
446
+ geom_mean_lines: the same at the mean
447
+ """
448
+
449
+ _ref = staticmethod(np.median)
450
+
451
+
452
+ def _ggplot(target: Any) -> ggplot:
453
+ if not isinstance(target, ggplot):
454
+ raise UnsupportedTargetError(f"sdvplot.plotnine draws on a plotnine ggplot, got {type(target).__name__}")
455
+ return target
456
+
457
+
458
+ def _frame(x: Any, y: Any, ids: Any, column: str, season: Any = None) -> pd.DataFrame:
459
+ """The layer data: one row per point, with its season, so plotnine's per-panel copies of a row keep it."""
460
+ xs, ys, ts = _unpack(x)[0], _unpack(y)[0], _unpack(ids)[0]
461
+ if not len(xs) == len(ys) == len(ts):
462
+ raise ValueError(f"x, y and teams must have the same length, got {len(xs)}, {len(ys)} and {len(ts)}")
463
+ return pd.DataFrame({"x": xs, "y": ys, column: ts, "season": pd.Series(_seasons(season, len(ts)), dtype=object)})
464
+
465
+
466
+ def add_logos(
467
+ target: ggplot,
468
+ x: Any,
469
+ y: Any,
470
+ teams: Any,
471
+ *,
472
+ league: str,
473
+ season: Any = None,
474
+ height: float = 0.1,
475
+ alpha: float = 1,
476
+ variant: str = "default",
477
+ id_system: str = "auto",
478
+ ) -> ggplot:
479
+ """A copy of the plot with each team's logo at its (x, y); the front door's plotnine adapter.
480
+
481
+ Args:
482
+ target: A plotnine ggplot.
483
+ x: The points' x positions (read by position).
484
+ y: The points' y positions, the same length as ``x``.
485
+ teams: The team for each point, in any id system ``resolve()`` understands.
486
+ league: The SDV league key, e.g. "nfl".
487
+ season: One season, or one per point, to pick each team's mark for that era.
488
+ height: The logo height as a fraction of the panel height, in (0, 1].
489
+ alpha: Opacity, 0 to 1.
490
+ variant: "default", "dark", or a named variant from ``marks()``.
491
+ id_system: The id system of ``teams``; "auto" tries each in order.
492
+
493
+ Returns:
494
+ ggplot: A new plot with a ``geom_sdv_logos`` layer; ``target`` is unchanged.
495
+
496
+ Raises:
497
+ ValueError: If the inputs differ in length.
498
+ UnsupportedTargetError: (a TypeError) If ``target`` is not a plotnine ggplot.
499
+ InputError: (a ValueError) If ``height`` or ``alpha`` is out of range, or ``season`` is not a year (or a list
500
+ whose length does not match the teams); when the plot is drawn, if ``league``, ``id_system`` or ``variant``
501
+ is unknown, or a season is not a year or is outside the seasons sdvplot knows for the league.
502
+ OfflineError: When the plot is drawn, if the logo manifest or a mark's image is neither cached nor downloadable
503
+ (a DownloadError, also an OSError, when the CDN answers with an error status; an IntegrityError when it
504
+ sends a file that does not match the manifest's sha256, or one PIL cannot decode).
505
+ UnsafeDownloadError: (an OSError) When the plot is drawn, if a download is refused: larger than the byte cap,
506
+ past the deadline, or redirected away from https.
507
+ UnsafeCachePathError: (a ValueError) When the plot is drawn, if the manifest's sha256 or extension for a mark
508
+ would put the file outside the cache directory.
509
+ OptionalDependencyError: When the plot is drawn, if a mark is an SVG and the ``svg`` extra is not installed.
510
+
511
+ Example:
512
+ ::
513
+
514
+ import pandas as pd
515
+ import sdvplot
516
+ from plotnine import aes, geom_point, ggplot
517
+
518
+ df = pd.DataFrame({"epa": [0.2, 0.15], "sr": [0.48, 0.47]})
519
+ p = ggplot(df, aes("epa", "sr")) + geom_point()
520
+ p2 = sdvplot.add_logos(p, [0.2], [0.48], ["KC"], league="nfl")
521
+
522
+ See Also:
523
+ sdvplotR geom_nfl_logos(): https://sdvplotR.sportsdataverse.org/ ;
524
+ sdvplot.plotnine.geom_sdv_logos: the layer this adds
525
+ """
526
+ layer = geom_sdv_logos(
527
+ aes("x", "y", team="team", season="season"), data=_frame(x, y, teams, "team", season), inherit_aes=False,
528
+ league=league, height=height, alpha=alpha, variant=variant, id_system=id_system,
529
+ ) # fmt: skip
530
+ return _ggplot(target) + layer
531
+
532
+
533
+ def add_wordmarks(
534
+ target: ggplot,
535
+ x: Any,
536
+ y: Any,
537
+ teams: Any,
538
+ *,
539
+ league: str,
540
+ season: Any = None,
541
+ height: float = 0.1,
542
+ alpha: float = 1,
543
+ variant: str = "default",
544
+ id_system: str = "auto",
545
+ ) -> ggplot:
546
+ """A copy of the plot with each team's wordmark at its (x, y).
547
+
548
+ Args:
549
+ target: A plotnine ggplot.
550
+ x: The points' x positions (read by position).
551
+ y: The points' y positions, the same length as ``x``.
552
+ teams: The team for each point.
553
+ league: The SDV league key, e.g. "nfl".
554
+ season: One season, or one per point, to pick each team's wordmark for that era.
555
+ height: The wordmark height as a fraction of the panel height, in (0, 1].
556
+ alpha: Opacity, 0 to 1.
557
+ variant: "default", "dark", or a named variant from ``marks()``.
558
+ id_system: The id system of ``teams``.
559
+
560
+ Returns:
561
+ ggplot: A new plot with a ``geom_sdv_wordmarks`` layer.
562
+
563
+ Raises:
564
+ ValueError: If the inputs differ in length.
565
+ UnsupportedTargetError: (a TypeError) If ``target`` is not a plotnine ggplot.
566
+ InputError: (a ValueError) If ``height`` or ``alpha`` is out of range, or ``season`` is not a year (or a list
567
+ whose length does not match the teams); when the plot is drawn, if ``league``, ``id_system`` or ``variant``
568
+ is unknown, or a season is not a year or is outside the seasons sdvplot knows for the league.
569
+ OfflineError: When the plot is drawn, if the logo manifest or a mark's image is neither cached nor downloadable
570
+ (a DownloadError, also an OSError, when the CDN answers with an error status; an IntegrityError when it
571
+ sends a file that does not match the manifest's sha256, or one PIL cannot decode).
572
+ UnsafeDownloadError: (an OSError) When the plot is drawn, if a download is refused: larger than the byte cap,
573
+ past the deadline, or redirected away from https.
574
+ UnsafeCachePathError: (a ValueError) When the plot is drawn, if the manifest's sha256 or extension for a mark
575
+ would put the file outside the cache directory.
576
+ OptionalDependencyError: When the plot is drawn, if a mark is an SVG and the ``svg`` extra is not installed.
577
+
578
+ Example:
579
+ ::
580
+
581
+ import pandas as pd
582
+ import sdvplot
583
+ from plotnine import aes, geom_point, ggplot
584
+
585
+ df = pd.DataFrame({"epa": [0.2, 0.15], "sr": [0.48, 0.47]})
586
+ p = ggplot(df, aes("epa", "sr")) + geom_point()
587
+ p2 = sdvplot.add_wordmarks(p, [0.2], [0.48], ["KC"], league="nfl")
588
+
589
+ See Also:
590
+ sdvplotR geom_nfl_wordmarks(): https://sdvplotR.sportsdataverse.org/ ;
591
+ sdvplot.plotnine.geom_sdv_wordmarks: the layer this adds
592
+ """
593
+ layer = geom_sdv_wordmarks(
594
+ aes("x", "y", team="team", season="season"), data=_frame(x, y, teams, "team", season), inherit_aes=False,
595
+ league=league, height=height, alpha=alpha, variant=variant, id_system=id_system,
596
+ ) # fmt: skip
597
+ return _ggplot(target) + layer
598
+
599
+
600
+ def add_headshots(
601
+ target: ggplot,
602
+ x: Any,
603
+ y: Any,
604
+ players: Any,
605
+ *,
606
+ league: str,
607
+ height: float = 0.1,
608
+ alpha: float = 1,
609
+ id_system: str = "espn",
610
+ ) -> ggplot:
611
+ """A copy of the plot with each player's headshot at its (x, y).
612
+
613
+ Args:
614
+ target: A plotnine ggplot.
615
+ x: The points' x positions (read by position).
616
+ y: The points' y positions, the same length as ``x``.
617
+ players: The player id for each point.
618
+ league: The SDV league key, e.g. "nfl".
619
+ height: The headshot height as a fraction of the panel height, in (0, 1].
620
+ alpha: Opacity, 0 to 1.
621
+ id_system: "espn" or "gsis" (NFL).
622
+
623
+ Returns:
624
+ ggplot: A new plot with a ``geom_sdv_headshots`` layer.
625
+
626
+ Raises:
627
+ ValueError: If the inputs differ in length.
628
+ UnsupportedTargetError: (a TypeError) If ``target`` is not a plotnine ggplot.
629
+ InputError: (a ValueError) If ``height`` or ``alpha`` is out of range; when the plot is drawn, if ``league`` has
630
+ no ESPN headshots or ``id_system`` is not valid for it.
631
+ OfflineError: When the plot is drawn, if a headshot, or with ``id_system="gsis"`` the nflverse player table, is
632
+ neither cached nor downloadable (a DownloadError, also an OSError, for an HTTP error status).
633
+ UnsafeDownloadError: (an OSError) When the plot is drawn, if a download is refused: larger than the byte cap,
634
+ past the deadline, or redirected away from https.
635
+
636
+ Example:
637
+ ::
638
+
639
+ import pandas as pd
640
+ import sdvplot
641
+ from plotnine import aes, geom_point, ggplot
642
+
643
+ df = pd.DataFrame({"epa": [0.2, 0.15], "sr": [0.48, 0.47]})
644
+ p = ggplot(df, aes("epa", "sr")) + geom_point()
645
+ p2 = sdvplot.add_headshots(p, [0.2], [0.48], ["3139477"], league="nfl")
646
+
647
+ See Also:
648
+ sdvplotR geom_nfl_headshots(): https://sdvplotR.sportsdataverse.org/ ;
649
+ sdvplot.plotnine.geom_sdv_headshots: the layer this adds
650
+ """
651
+ layer = geom_sdv_headshots(
652
+ aes("x", "y", player_id="player_id"), data=_frame(x, y, players, "player_id"), inherit_aes=False,
653
+ league=league, height=height, alpha=alpha, id_system=id_system,
654
+ ) # fmt: skip
655
+ return _ggplot(target) + layer
656
+
657
+
658
+ class _AxisLogos:
659
+ """Added with ``+``: pads the axis text to make room, then (as a plotnine watermark, drawn after the breaks and
660
+ labels) runs the matplotlib axis-logo routine on every panel."""
661
+
662
+ def __init__(self, axis: str, **kw: Any) -> None:
663
+ if axis not in ("x", "y"):
664
+ raise ValueError(f"axis must be 'x' or 'y', got {axis!r}")
665
+ check_height(kw["height"])
666
+ _check_mark_type(kw["mark_type"])
667
+ self.axis, self.kw = axis, kw
668
+
669
+ def __radd__(self, gg: ggplot) -> ggplot:
670
+ size = gg.theme.getp("figure_size") or (6.4, 4.8)
671
+ points = self.kw["height"] * size[1] * 72 * 0.8 # ~ the panel's share of the figure height
672
+ margin: Any = {"t": points, "unit": "pt"} if self.axis == "x" else {"r": points, "unit": "pt"}
673
+ gg += p9_theme(**{f"axis_text_{self.axis}": element_text(margin=margin)})
674
+ gg.watermarks.append(self)
675
+ return gg
676
+
677
+ def draw(self, figure: Figure) -> None:
678
+ # every panel's labels are placed together first, so each skip reason warns once, not once per panel
679
+ kw = self.kw
680
+ labels = list(dict.fromkeys(lab for ax in figure.axes for lab in _in_view(ax, self.axis)[1]))
681
+ zeros = [0.0] * len(labels)
682
+ place(zeros, zeros, labels, league=kw["league"], season=kw["season"], kind=kw["mark_type"],
683
+ variant=kw["variant"], id_system=kw["id_system"]) # fmt: skip
684
+ for ax in figure.axes:
685
+ _mpl_axis_logos(ax, self.axis, **kw, warn=False)
686
+
687
+
688
+ def axis_logos(
689
+ target: ggplot,
690
+ axis: str,
691
+ *,
692
+ league: str,
693
+ season: Any = None,
694
+ height: float = 0.1,
695
+ variant: str = "default",
696
+ mark_type: str = "logo",
697
+ id_system: str = "auto",
698
+ ) -> ggplot:
699
+ """A copy of the plot whose team axis shows logos (or wordmarks) instead of tick labels.
700
+
701
+ Args:
702
+ target: A plotnine ggplot whose ``axis`` is a discrete team axis.
703
+ axis: "x" or "y".
704
+ league: The SDV league key, e.g. "nfl".
705
+ season: One season for every label.
706
+ height: The image height as a fraction of the panel height, in (0, 1].
707
+ variant: "default", "dark", or a named variant from ``marks()``.
708
+ mark_type: "logo" or "wordmark".
709
+ id_system: The id system of the labels.
710
+
711
+ Returns:
712
+ ggplot: A new plot; labels that are not teams stay as text, with one SdvplotWarning when drawn.
713
+
714
+ Raises:
715
+ ValueError: If ``axis`` is not "x"/"y".
716
+ UnsupportedTargetError: (a TypeError) If ``target`` is not a plotnine ggplot.
717
+ InputError: (a ValueError) If ``height`` is out of range or ``mark_type`` is not "logo"/"wordmark"; when the
718
+ plot is drawn, if ``league``, ``id_system`` or ``variant`` is unknown, or a season is not a year or is
719
+ outside the seasons sdvplot knows for the league.
720
+ OfflineError: When the plot is drawn, if the logo manifest or a mark's image is neither cached nor downloadable
721
+ (a DownloadError, also an OSError, when the CDN answers with an error status; an IntegrityError when it
722
+ sends a file that does not match the manifest's sha256, or one PIL cannot decode).
723
+ UnsafeDownloadError: (an OSError) When the plot is drawn, if a download is refused: larger than the byte cap,
724
+ past the deadline, or redirected away from https.
725
+ UnsafeCachePathError: (a ValueError) When the plot is drawn, if the manifest's sha256 or extension for a mark
726
+ would put the file outside the cache directory.
727
+ OptionalDependencyError: When the plot is drawn, if a mark is an SVG and the ``svg`` extra is not installed.
728
+
729
+ Example:
730
+ ::
731
+
732
+ import pandas as pd
733
+ import sdvplot
734
+ from plotnine import aes, geom_col, ggplot
735
+
736
+ df = pd.DataFrame({"team": ["KC", "BUF", "BAL"], "epa": [0.2, 0.15, 0.1]})
737
+ p = ggplot(df, aes("team", "epa")) + geom_col()
738
+ p2 = sdvplot.axis_logos(p, "x", league="nfl")
739
+
740
+ See Also:
741
+ sdvplotR element_sdv_logo(): https://sdvplotR.sportsdataverse.org/ ;
742
+ sdvplot.plotnine.title_image: an image beside the plot title
743
+ """
744
+ return _ggplot(target) + _AxisLogos(
745
+ axis, league=league, season=season, height=height, variant=variant, mark_type=mark_type, id_system=id_system
746
+ )
747
+
748
+
749
+ class _TitleImage:
750
+ """Added with ``+``: sets the title, then (as a plotnine watermark, drawn after the figure texts) puts the image
751
+ beside the rendered title, aligned with it as the theme's ``plot_title`` alignment says."""
752
+
753
+ def __init__(self, image: Any, title: str, league: str | None, season: Any, side: str, height: float) -> None:
754
+ self.height = _check_title_image(side, height)
755
+ self.side, self.title = side, title or " " # a blank title still gives the image a line to sit on
756
+ self.source = _title_source(image, league, season) # resolved (and warned about) when built, not drawn
757
+
758
+ def __radd__(self, gg: ggplot) -> ggplot:
759
+ gg += labs(title=self.title)
760
+ gg.watermarks = [w for w in gg.watermarks if not isinstance(w, _TitleImage)] + [self] # one title image
761
+ return gg
762
+
763
+ def draw(self, figure: Figure) -> None:
764
+ if self.source is None:
765
+ return
766
+ text = next((t for t in figure.texts if t.get_text() == self.title), None)
767
+ if text is None:
768
+ warn(f"title_image: the plot's title is no longer {self.title!r}; add title_image() after labs(title=...)")
769
+ return
770
+
771
+ def align() -> float: # plotnine left-aligns the text and places it by the theme's plot_title ha
772
+ theme_ = getattr(figure.get_layout_engine(), "theme", None)
773
+ return _align(theme_.getp(("plot_title", "ha")) if theme_ is not None else "left")
774
+
775
+ _add_title_image(figure, text, self.source, self.side, self.height, align)
776
+
777
+
778
+ def title_image(
779
+ image: Any,
780
+ title: str = "",
781
+ *,
782
+ league: str | None = None,
783
+ season: Any = None,
784
+ side: str = "left",
785
+ height: float = 15,
786
+ ) -> Any:
787
+ """A plot title with an image (a team logo, or any image) beside it, added to a ggplot with ``+``.
788
+
789
+ The pair follows the theme's ``plot_title`` alignment, like the image inside sdvplotR's title: plotnine centres a
790
+ lone title and left-aligns one with a subtitle.
791
+
792
+ Args:
793
+ image: A team, in any id system ``resolve()`` understands, when ``league`` is given; otherwise an image URL
794
+ (https; http is refused) or a local file path.
795
+ title: The title text; it replaces ``labs(title=...)``, so add ``title_image`` after any ``labs``.
796
+ league: The SDV league key, e.g. "nfl"; None reads ``image`` as a URL or path.
797
+ season: One season, to pick the team's logo for that era.
798
+ side: "left" or "right" of the title text.
799
+ height: The image height in points (1/72 inch). The title keeps its own line height, so an image much taller
800
+ than the text needs room: a ``plot_title`` margin in ``theme()``.
801
+
802
+ Returns:
803
+ object: An object to add to a ggplot; the image is loaded now, and an unknown team, or an image by URL or path
804
+ that cannot be read, gives one SdvplotWarning now (the title is drawn without the image). A second
805
+ ``title_image`` added to the same plot replaces the first.
806
+
807
+ Raises:
808
+ TypeError: If ``league`` is given and ``image`` is not one team.
809
+ InputError: (a ValueError) If ``height`` is not a number of points of at least 1, or ``league`` is given and it
810
+ is unknown or ``season`` is not a year or is outside the seasons sdvplot knows for the league.
811
+ ValueError: If ``side`` is not "left"/"right".
812
+ OfflineError: If ``league`` is given and the team's logo (or the logo manifest) is neither cached nor
813
+ downloadable (a DownloadError, also an OSError, when the CDN answers with an error status; an IntegrityError
814
+ when it sends a file that does not match the manifest's sha256, or one PIL cannot decode). An image by URL
815
+ or path that cannot be read is skipped with a warning instead.
816
+ UnsafeDownloadError: (an OSError) If ``league`` is given and a download is refused: larger than the byte cap,
817
+ past the deadline, or redirected away from https.
818
+ UnsafeCachePathError: (a ValueError) If ``league`` is given and the manifest's sha256 or extension for the logo
819
+ would put the file outside the cache directory.
820
+ OptionalDependencyError: If ``league`` is given, the logo is an SVG and the ``svg`` extra is not installed.
821
+
822
+ Example:
823
+ ::
824
+
825
+ import pandas as pd
826
+ from plotnine import aes, geom_point, ggplot
827
+ from sdvplot.plotnine import title_image
828
+
829
+ df = pd.DataFrame({"epa": [0.2, 0.15], "sr": [0.48, 0.47]})
830
+ p = ggplot(df, aes("epa", "sr")) + geom_point() + title_image("KC", "Chiefs", league="nfl", height=20)
831
+
832
+ See Also:
833
+ sdvplotR ggtitle_image(): https://sdvplotR.sportsdataverse.org/reference/ggtitle_image.html ;
834
+ sdvplot.matplotlib.title_image: the same for matplotlib.
835
+ """
836
+ return _TitleImage(image, title, league, season, side, height)
837
+
838
+
839
+ def team_tiers(
840
+ data: Any,
841
+ league: str,
842
+ *,
843
+ title: str | None = None,
844
+ subtitle: str | None = _tiers.SUBTITLE,
845
+ caption: str | None = None,
846
+ tier_desc: dict[Any, str] | None = None,
847
+ presort: bool = False,
848
+ alpha: float = 0.8,
849
+ height: float | None = None,
850
+ no_line_below_tier: Any = None,
851
+ devel: bool = False,
852
+ theme: Literal["dark", "light"] = "dark",
853
+ ) -> ggplot:
854
+ """A tier list as a ggplot: each team's logo in its tier's row, tier 1 on top, on a dark (sdvplotR) or light theme.
855
+
856
+ Args:
857
+ data: A pandas or polars DataFrame with ``tier_no`` (1 is the top tier) and ``team`` (any id system
858
+ ``resolve()`` understands), and optionally ``tier_rank``, the position within the tier; without it, teams
859
+ keep their order in ``data``.
860
+ league: The SDV league key, e.g. "nfl".
861
+ title: The title; None gives "{LEAGUE} Team Tiers", "" none.
862
+ subtitle: The subtitle; None or "" for none.
863
+ caption: The caption; None for none.
864
+ tier_desc: Each tier's label, keyed by tier number; None gives sdvplotR's (1 "Elite" ... 5 "What are they
865
+ doing?"). Labels wrap at 15 characters; a tier without one gets none.
866
+ presort: Sort teams alphabetically within each tier (ignores ``tier_rank``).
867
+ alpha: Logo opacity, 0 to 1.
868
+ height: Logo height as a fraction of the panel height; None gives 0.1, about the largest height at
869
+ which 32 logos in 5 tiers (7, 7, 6, 6, 6) neither overlap nor leave the panel at the default 6.4 x 4.8 in
870
+ figure.
871
+ no_line_below_tier: A tier number, or several, with no separator line below.
872
+ devel: Draw each team as text instead of its logo (fast, and needs no download).
873
+ theme: "dark" (sdvplotR's: a near-black background) or "light" (white, for dark logos such as Ohio State's,
874
+ Texas A&M's or Penn State's, which vanish on dark).
875
+
876
+ Returns:
877
+ ggplot: The plot; a team that does not resolve is skipped with one SdvplotWarning, keeping its slot.
878
+
879
+ Raises:
880
+ TypeError: If ``data`` is not a DataFrame, or ``tier_no``/``tier_rank`` hold non-numbers.
881
+ InputError: (a ValueError) If ``height``/``alpha`` is out of range, or ``league`` is unknown.
882
+ ValueError: If ``data`` lacks ``tier_no`` or ``team``, has no row with a tier, or ``theme`` is not "dark" or
883
+ "light".
884
+ OfflineError: Unless ``devel=True``, when the plot is drawn, if the logo manifest or a logo is neither cached
885
+ nor downloadable (a DownloadError, also an OSError, for an HTTP error status; an IntegrityError for a file
886
+ that does not match the manifest's sha256, or one PIL cannot decode).
887
+ UnsafeDownloadError: (an OSError) Unless ``devel=True``, when the plot is drawn, if a download is refused.
888
+ UnsafeCachePathError: (a ValueError) Unless ``devel=True``, when the plot is drawn, if the manifest's sha256 or
889
+ extension for a logo would put the file outside the cache directory.
890
+ OptionalDependencyError: Unless ``devel=True``, when the plot is drawn, if a logo is an SVG and the ``svg``
891
+ extra is not installed.
892
+
893
+ Example:
894
+ ::
895
+
896
+ import pandas as pd
897
+ from sdvplot.plotnine import team_tiers
898
+
899
+ df = pd.DataFrame({"tier_no": [1, 1, 2, 3], "team": ["KC", "BUF", "BAL", "NYJ"]})
900
+ p = team_tiers(df, "nfl", caption="data: nflverse")
901
+
902
+ # Draft it as text first:
903
+ p = team_tiers(df, "nfl", devel=True)
904
+
905
+ # Dark logos on a white background:
906
+ p = team_tiers(df, "cfb", theme="light")
907
+
908
+ See Also:
909
+ sdvplotR sdv_team_tiers(): https://sdvplotR.sportsdataverse.org/reference/sdv_team_tiers.html ;
910
+ sdvplot.matplotlib.team_tiers: the same as a matplotlib Figure.
911
+ """
912
+ t = _tiers.prepare(
913
+ data, league, title=title, subtitle=subtitle, caption=caption, tier_desc=tier_desc, presort=presort,
914
+ alpha=alpha, height=height, no_line_below_tier=no_line_below_tier, theme=theme,
915
+ ) # fmt: skip
916
+ frame = pd.DataFrame({"x": t.x, "y": t.y, "team": t.team_ids, "label": t.labels})
917
+ if devel:
918
+ marks: Any = geom_text(aes(label="label"), color=t.text)
919
+ else:
920
+ marks = geom_sdv_logos(aes(team="team"), league=league, id_system="team_id", height=t.height, alpha=t.alpha)
921
+ texts = {"title": t.title, "subtitle": t.subtitle, "caption": t.caption}
922
+ return (
923
+ ggplot(frame, aes("x", "y"))
924
+ + geom_hline(yintercept=t.lines, color=t.line_color)
925
+ + marks
926
+ + scale_x_continuous(limits=t.xlim, expand=(0, 0))
927
+ + scale_y_reverse(limits=t.ylim, breaks=t.breaks, labels=t.break_labels, expand=(0, 0))
928
+ + labs(**{k: v for k, v in texts.items() if v})
929
+ + theme_minimal(base_size=11.5)
930
+ + p9_theme(
931
+ plot_title=element_text(color=t.text, weight="bold"),
932
+ plot_subtitle=element_text(color=t.muted),
933
+ plot_caption=element_text(color=t.muted, ha="right"),
934
+ plot_title_position="plot",
935
+ axis_text_x=element_blank(),
936
+ axis_text_y=element_text(color=t.text, weight="bold", size=11.5 * 0.8 * 1.1), # sdvplotR: rel(1.1)
937
+ axis_title=element_blank(),
938
+ panel_grid=element_blank(),
939
+ plot_background=element_rect(fill=t.bg, color=t.bg),
940
+ panel_background=element_rect(fill=t.bg, color=t.bg),
941
+ )
942
+ )
943
+
944
+
945
+ def _scale(
946
+ kind: Any,
947
+ league: str,
948
+ which: Which,
949
+ season: Any,
950
+ na_value: str,
951
+ kwargs: dict[str, Any],
952
+ id_system: IdSystem = "auto",
953
+ strict: bool = False,
954
+ ) -> Any:
955
+ _column(which) # "primary" / "secondary", else ValueError now rather than when the plot is drawn
956
+
957
+ class _TeamScale(kind):
958
+ def __init__(self) -> None:
959
+ super().__init__(values={}, na_value=na_value, **kwargs)
960
+
961
+ def map(self, x: Any, limits: Any = None) -> Any:
962
+ values = [v for v in (limits if limits is not None else self.final_limits) if v is not None]
963
+ colors = team_colors(league, values, which=which, season=season, id_system=id_system, strict=strict)
964
+ self._values = {v: c for v, c in zip(values, colors, strict=True) if c is not None}
965
+ self.palette = lambda n: [self._values.get(v, na_value) for v in values]
966
+ return [self._values.get(v, na_value) for v in x]
967
+
968
+ return _TeamScale()
969
+
970
+
971
+ def scale_color_sdv(
972
+ league: str,
973
+ *,
974
+ which: Which = "primary",
975
+ season: Any = None,
976
+ id_system: IdSystem = "auto",
977
+ strict: bool = False,
978
+ na_value: str = "grey",
979
+ **kwargs: Any,
980
+ ) -> Any:
981
+ """A discrete color scale that maps each team value (any id system) to its team color.
982
+
983
+ Args:
984
+ league: The SDV league key, e.g. "nfl".
985
+ which: "primary" or "secondary".
986
+ season: One season for every value.
987
+ id_system: The id system of the team values: ``"auto"`` (the default) tries them in order; pass
988
+ ``"nhl_id"`` for NHL stats ids, which ``"auto"`` never tries.
989
+ strict: Raise ``UnresolvedTeamError`` when the plot is drawn and a value does not resolve, instead of drawing
990
+ it in ``na_value`` with one ``SdvplotWarning``.
991
+ na_value: The color of values that are not teams.
992
+ **kwargs: Passed to plotnine's ``scale_color_manual`` (``name``, ``breaks``, ``guide``, ...).
993
+
994
+ Returns:
995
+ scale: A plotnine color scale.
996
+
997
+ Raises:
998
+ InputError: (a ValueError) If ``which`` is not "primary" or "secondary"; when the plot is drawn, if ``league``
999
+ or ``id_system`` is unknown or ``season`` is not a year or is outside the seasons sdvplot knows for the
1000
+ league.
1001
+ UnresolvedTeamError: (a ValueError) With ``strict=True``, when the plot is drawn and a value does not resolve.
1002
+
1003
+ Example:
1004
+ ::
1005
+
1006
+ import pandas as pd
1007
+ from plotnine import aes, geom_point, ggplot
1008
+ from sdvplot.plotnine import scale_color_sdv
1009
+
1010
+ df = pd.DataFrame({"team": ["KC", "BUF"], "epa": [0.2, 0.15], "sr": [0.48, 0.47]})
1011
+ p = ggplot(df, aes("epa", "sr", color="team")) + geom_point() + scale_color_sdv("nfl")
1012
+
1013
+ See Also:
1014
+ sdvplotR scale_color_sdv(): https://sdvplotR.sportsdataverse.org/ ;
1015
+ sdvplot.plotnine.scale_fill_sdv: the fill scale
1016
+ """
1017
+ return _scale(scale_color_manual, league, which, season, na_value, kwargs, id_system, strict)
1018
+
1019
+
1020
+ def scale_fill_sdv(
1021
+ league: str,
1022
+ *,
1023
+ which: Which = "primary",
1024
+ season: Any = None,
1025
+ id_system: IdSystem = "auto",
1026
+ strict: bool = False,
1027
+ na_value: str = "grey",
1028
+ **kwargs: Any,
1029
+ ) -> Any:
1030
+ """A discrete fill scale that maps each team value (any id system) to its team color.
1031
+
1032
+ Args:
1033
+ league: The SDV league key, e.g. "nfl".
1034
+ which: "primary" or "secondary".
1035
+ season: One season for every value.
1036
+ id_system: The id system of the team values: ``"auto"`` (the default) tries them in order; pass
1037
+ ``"nhl_id"`` for NHL stats ids, which ``"auto"`` never tries.
1038
+ strict: Raise ``UnresolvedTeamError`` when the plot is drawn and a value does not resolve, instead of drawing
1039
+ it in ``na_value`` with one ``SdvplotWarning``.
1040
+ na_value: The color of values that are not teams.
1041
+ **kwargs: Passed to plotnine's ``scale_fill_manual``.
1042
+
1043
+ Returns:
1044
+ scale: A plotnine fill scale.
1045
+
1046
+ Raises:
1047
+ InputError: (a ValueError) If ``which`` is not "primary" or "secondary"; when the plot is drawn, if ``league``
1048
+ or ``id_system`` is unknown or ``season`` is not a year or is outside the seasons sdvplot knows for the
1049
+ league.
1050
+ UnresolvedTeamError: (a ValueError) With ``strict=True``, when the plot is drawn and a value does not resolve.
1051
+
1052
+ Example:
1053
+ ::
1054
+
1055
+ import pandas as pd
1056
+ from plotnine import aes, geom_col, ggplot
1057
+ from sdvplot.plotnine import scale_fill_sdv
1058
+
1059
+ df = pd.DataFrame({"team": ["KC", "BUF"], "epa": [0.2, 0.15]})
1060
+ p = ggplot(df, aes("team", "epa", fill="team")) + geom_col() + scale_fill_sdv("nfl")
1061
+
1062
+ See Also:
1063
+ sdvplotR scale_fill_sdv(): https://sdvplotR.sportsdataverse.org/ ;
1064
+ sdvplot.plotnine.scale_color_sdv: the color scale
1065
+ """
1066
+ return _scale(scale_fill_manual, league, which, season, na_value, kwargs, id_system, strict)
1067
+
1068
+
1069
+ def _drawn(target: ggplot) -> Figure:
1070
+ return target.draw()
1071
+
1072
+
1073
+ def _drawn_marks(target: ggplot) -> list[tuple[Any, ...]]:
1074
+ """Test hook: draw the plot, then (team_id, x, y, height, url) for each mark image on any panel."""
1075
+ import matplotlib.pyplot as plt
1076
+
1077
+ fig = _drawn(target)
1078
+ try:
1079
+ return [m for ax in fig.axes for m in _mpl_drawn_marks(ax)]
1080
+ finally:
1081
+ plt.close(fig)
1082
+
1083
+
1084
+ def _drawn_axis_marks(target: ggplot, axis: str) -> list[tuple[str, float, float]]:
1085
+ """Test hook: draw the plot, then (team_id, tick position, measured height) for each axis image on the first
1086
+ panel."""
1087
+ import matplotlib.pyplot as plt
1088
+
1089
+ fig = _drawn(target)
1090
+ try:
1091
+ return _mpl_drawn_axis_marks(fig.axes[0], axis)
1092
+ finally:
1093
+ plt.close(fig)
1094
+
1095
+
1096
+ def _visible_axis_labels(target: ggplot, axis: str) -> list[str]:
1097
+ """Test hook: draw the plot, then the first panel's tick labels still shown as text."""
1098
+ import matplotlib.pyplot as plt
1099
+
1100
+ fig = _drawn(target)
1101
+ try:
1102
+ return _mpl_visible_axis_labels(fig.axes[0], axis)
1103
+ finally:
1104
+ plt.close(fig)