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/matplotlib.py ADDED
@@ -0,0 +1,934 @@
1
+ """The matplotlib adapter: logos, wordmarks and headshots on Axes, single-Axes Figures and seaborn grids.
2
+
3
+ sdvplot routes matplotlib and seaborn targets here (sdvplot._dispatch). An image's height is a fraction of its Axes'
4
+ height at draw time (_AxesFractionImage), so it holds at any dpi or figure size. plotnine draws through
5
+ _draw_placements too. Cartopy GeoAxes are matplotlib Axes: transform= takes the CRS of the caller's coordinates.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numbers
11
+ import sys
12
+ from collections.abc import Callable
13
+ from typing import Any, Literal
14
+
15
+ from sdvplot._errors import InputError, OfflineError, UnsupportedTargetError, requires_extra, warn
16
+
17
+ with requires_extra("mpl"):
18
+ import numpy as np
19
+ from matplotlib.axes import Axes
20
+ from matplotlib.figure import Figure
21
+ from matplotlib.offsetbox import AnnotationBbox, OffsetImage
22
+ from matplotlib.text import Text
23
+ from matplotlib.ticker import FixedFormatter, FixedLocator
24
+ from matplotlib.transforms import Affine2D, Bbox, Transform
25
+ from PIL import Image
26
+
27
+ from sdvplot import _tiers
28
+ from sdvplot._images import MAX_SIZE, load_mark_image, load_path_image, load_url_image, logo_image
29
+ from sdvplot._placement import Placement, _real, _warn_skipped, check_alpha, check_height, place, place_images
30
+
31
+ _SUPPORTS_AXIS_LOGOS = True
32
+ _MAX_IMAGE_HEIGHT = 512 # px handed to matplotlib: sharp at 0.25 of a 6-inch Axes at 300 dpi, small in PDF/SVG
33
+ _TITLE_GAP = 4 # points between a title image and its title text
34
+ _HA = {"left": 0.0, "center": 0.5, "right": 1.0}
35
+
36
+ __all__ = ["add_headshots", "add_images", "add_logos", "add_wordmarks", "axis_logos", "team_tiers", "title_image"]
37
+
38
+
39
+ def __dir__() -> list[str]: # dir() and tab completion show the public API only
40
+ return list(__all__)
41
+
42
+
43
+ class _AxesFractionImage(OffsetImage):
44
+ """An OffsetImage drawn at a fixed fraction of its Axes' height, whatever the dpi or figure size."""
45
+
46
+ def __init__(self, arr: np.ndarray, ax: Axes, fraction: float, **kwargs: Any) -> None:
47
+ super().__init__(arr, **kwargs)
48
+ self._sdv_ax, self._sdv_fraction = ax, fraction
49
+ self._sdv_rows, self._sdv_cols = arr.shape[:2]
50
+
51
+ def get_bbox(self, renderer: Any) -> Bbox:
52
+ h = self._sdv_fraction * self._sdv_ax.bbox.height
53
+ return Bbox.from_bounds(0, 0, h * self._sdv_cols / self._sdv_rows, h)
54
+
55
+
56
+ def _rgba_array(img: Image.Image) -> np.ndarray:
57
+ """An image as an RGBA array, at most _MAX_IMAGE_HEIGHT pixels tall."""
58
+ img = img.convert("RGBA")
59
+ if img.height > _MAX_IMAGE_HEIGHT:
60
+ width = max(1, round(img.width * _MAX_IMAGE_HEIGHT / img.height))
61
+ img = img.resize((width, _MAX_IMAGE_HEIGHT), Image.Resampling.LANCZOS)
62
+ return np.asarray(img)
63
+
64
+
65
+ def _target_axes(target: Any) -> Axes:
66
+ """The one Axes to draw on: an Axes, a Figure's only Axes, a JointGrid's joint Axes, or a one-Axes grid."""
67
+ if isinstance(target, Axes):
68
+ return target
69
+ if isinstance(target, Figure):
70
+ axes = [a for a in target.axes if a.get_label() != "<colorbar>"]
71
+ if len(axes) == 1:
72
+ return axes[0]
73
+ raise ValueError(f"this Figure has {len(axes)} Axes; pass the Axes to draw on, e.g. fig.axes[0]")
74
+ joint = getattr(target, "ax_joint", None) # seaborn JointGrid
75
+ if isinstance(joint, Axes):
76
+ return joint
77
+ grid = getattr(target, "axes", None) # seaborn FacetGrid / PairGrid: an array of Axes
78
+ if grid is not None:
79
+ flat = list(np.ravel(grid))
80
+ if len(flat) == 1 and isinstance(flat[0], Axes):
81
+ return flat[0]
82
+ raise ValueError(f"this grid has {len(flat)} Axes; pass the Axes to draw on, e.g. g.axes.flat[0]")
83
+ raise UnsupportedTargetError(f"sdvplot.matplotlib cannot draw on a {type(target).__name__}")
84
+
85
+
86
+ def _image(p: Placement) -> np.ndarray:
87
+ if p.mark is None:
88
+ return _rgba_array(load_url_image(p.url))
89
+ # decoded no bigger than drawn: half the archive is 4096 px (64 MiB each decoded); a wide mark keeps its full
90
+ # height, up to the longest side sdvplot renders (a mark wider than 8:1 is drawn from a 4096 px decode)
91
+ size = min(MAX_SIZE, round(_MAX_IMAGE_HEIGHT * max(1.0, p.aspect or 1.0)))
92
+ return _rgba_array(load_mark_image(p.mark, size))
93
+
94
+
95
+ def _xycoords(ax: Axes, transform: Any) -> Any:
96
+ """The coordinates (x, y) are in: the Axes' data, or ``transform`` (a Cartopy CRS or a matplotlib Transform)."""
97
+ if transform is None:
98
+ geoaxes = sys.modules.get("cartopy.mpl.geoaxes") # loaded whenever a GeoAxes exists
99
+ if geoaxes is not None and isinstance(ax, geoaxes.GeoAxes):
100
+ raise ValueError(
101
+ "this is a Cartopy GeoAxes, whose data coordinates are the projection's: pass the CRS of x and y, "
102
+ "e.g. transform=ccrs.PlateCarree() for longitude/latitude"
103
+ )
104
+ return "data"
105
+ return transform._as_mpl_transform(ax) if hasattr(transform, "_as_mpl_transform") else transform
106
+
107
+
108
+ def _draw_placements(
109
+ ax: Axes,
110
+ placements: list[Placement],
111
+ *,
112
+ height: float,
113
+ alpha: float = 1.0,
114
+ zorder: float = 3,
115
+ xycoords: Any = "data",
116
+ images: dict[str, np.ndarray] | None = None,
117
+ ) -> list[AnnotationBbox]:
118
+ """Draw each placement centred on its (x, y) in ``xycoords``, ``height`` of the Axes tall.
119
+
120
+ A point outside the Axes is not drawn, whatever ``xycoords`` is (matplotlib only clips "data" by default).
121
+ ``images`` holds arrays already loaded, by url; the rest are loaded once each.
122
+ """
123
+ images = dict(images or {})
124
+ boxes = []
125
+ for p in placements:
126
+ if p.url not in images:
127
+ images[p.url] = _image(p)
128
+ box = AnnotationBbox(
129
+ _AxesFractionImage(images[p.url], ax, height, alpha=alpha),
130
+ (p.x, p.y),
131
+ xycoords=xycoords,
132
+ frameon=False,
133
+ pad=0,
134
+ zorder=zorder,
135
+ annotation_clip=True,
136
+ )
137
+ box._sdvplot_mark = (p.team_id, p.x, p.y, p.url) # type: ignore[attr-defined]
138
+ ax.add_artist(box)
139
+ boxes.append(box)
140
+ return boxes
141
+
142
+
143
+ def _add(
144
+ target: Any,
145
+ x: Any,
146
+ y: Any,
147
+ teams: Any,
148
+ *,
149
+ kind: str,
150
+ league: str,
151
+ season: Any,
152
+ height: float,
153
+ alpha: float,
154
+ variant: str,
155
+ zorder: float,
156
+ id_system: str,
157
+ transform: Any,
158
+ ) -> Any:
159
+ h, a = check_height(height), check_alpha(alpha)
160
+ ax = _target_axes(target)
161
+ xycoords = _xycoords(ax, transform)
162
+ placements = place(x, y, teams, league=league, season=season, kind=kind, variant=variant, id_system=id_system)
163
+ _draw_placements(ax, placements, height=h, alpha=a, zorder=zorder, xycoords=xycoords)
164
+ return target
165
+
166
+
167
+ def add_logos(
168
+ target: Any,
169
+ x: Any,
170
+ y: Any,
171
+ teams: Any,
172
+ *,
173
+ league: str,
174
+ season: Any = None,
175
+ height: float = 0.1,
176
+ alpha: float = 1,
177
+ variant: str = "default",
178
+ zorder: float = 3,
179
+ id_system: str = "auto",
180
+ transform: Any = None,
181
+ ) -> Any:
182
+ """Draw each team's logo centred on its (x, y) point of a matplotlib or seaborn plot.
183
+
184
+ Args:
185
+ target: A matplotlib Axes, a Figure with one Axes, or a seaborn grid with one Axes (or a JointGrid).
186
+ x: The points' x positions, in data coordinates (list, numpy array, or pandas/polars Series; read by position).
187
+ y: The points' y positions, the same length as ``x``.
188
+ teams: The team for each point, in any id system ``resolve()`` understands.
189
+ league: The SDV league key, e.g. "nfl".
190
+ season: One season, or one per point, to pick each team's mark for that era.
191
+ height: The logo height as a fraction of the Axes height, in (0, 1].
192
+ alpha: Opacity, 0 to 1.
193
+ variant: "default", "dark", or a named variant from ``marks()``.
194
+ zorder: matplotlib drawing order (3 draws above lines and markers).
195
+ id_system: The id system of ``teams``; "auto" tries each in order.
196
+ transform: The coordinates x and y are in, when not the Axes' data: a Cartopy CRS such as
197
+ ``ccrs.PlateCarree()`` (longitude/latitude, required on a GeoAxes) or a matplotlib Transform.
198
+ A mark whose position falls outside the Axes is not drawn, whatever the transform.
199
+
200
+ Returns:
201
+ object: ``target`` itself, drawn on.
202
+
203
+ Raises:
204
+ InputError: (a ValueError) If ``height`` or ``alpha`` is out of range, ``league``, ``id_system`` or ``variant``
205
+ is unknown, or ``season`` is not a year or is outside the seasons sdvplot knows for the league.
206
+ ValueError: If ``x``/``y``/``teams`` differ in length, the target has several Axes, or the target is a Cartopy
207
+ GeoAxes and ``transform`` is None.
208
+ UnsupportedTargetError: (a TypeError) If ``target`` is not a matplotlib Axes, a Figure or a seaborn grid.
209
+ OfflineError: If the logo manifest or a mark's image is neither cached nor downloadable (a DownloadError, also
210
+ an OSError, when the CDN answers with an error status; an IntegrityError when it sends a file that does not
211
+ match the manifest's sha256, or one PIL cannot decode).
212
+ UnsafeDownloadError: (an OSError) If a download is refused: larger than the byte cap, past the deadline, or
213
+ redirected away from https.
214
+ UnsafeCachePathError: (a ValueError) If the manifest's sha256 or extension for a mark would put the file outside
215
+ the cache directory.
216
+ OptionalDependencyError: If a mark is an SVG and the ``svg`` extra is not installed.
217
+
218
+ Example:
219
+ ::
220
+
221
+ import matplotlib.pyplot as plt
222
+ import sdvplot
223
+
224
+ fig, ax = plt.subplots()
225
+ ax.set_xlim(0, 30)
226
+ ax.set_ylim(-10, 0)
227
+ sdvplot.add_logos(ax, [10, 20], [-3, -7], ["KC", "BUF"], league="nfl", height=0.15)
228
+
229
+ # On a Cartopy map, at longitude/latitude:
230
+ # import cartopy.crs as ccrs
231
+ # ax = plt.axes(projection=ccrs.Robinson())
232
+ # ax.set_global()
233
+ # sdvplot.add_logos(ax, [-94.48], [39.05], ["KC"], league="nfl", transform=ccrs.PlateCarree())
234
+
235
+ See Also:
236
+ sdvplotR geom_nfl_logos(): https://sdvplotR.sportsdataverse.org/ ;
237
+ sdv-py: https://py.sportsdataverse.org/
238
+ """
239
+ return _add(
240
+ target, x, y, teams, kind="logo", league=league, season=season, height=height, alpha=alpha,
241
+ variant=variant, zorder=zorder, id_system=id_system, transform=transform,
242
+ ) # fmt: skip
243
+
244
+
245
+ def add_wordmarks(
246
+ target: Any,
247
+ x: Any,
248
+ y: Any,
249
+ teams: Any,
250
+ *,
251
+ league: str,
252
+ season: Any = None,
253
+ height: float = 0.1,
254
+ alpha: float = 1,
255
+ variant: str = "default",
256
+ zorder: float = 3,
257
+ id_system: str = "auto",
258
+ transform: Any = None,
259
+ ) -> Any:
260
+ """Draw each team's wordmark centred on its (x, y) point of a matplotlib or seaborn plot.
261
+
262
+ Args:
263
+ target: A matplotlib Axes, a Figure with one Axes, or a seaborn grid with one Axes (or a JointGrid).
264
+ x: The points' x positions, in data coordinates (read by position).
265
+ y: The points' y positions, the same length as ``x``.
266
+ teams: The team for each point, in any id system ``resolve()`` understands.
267
+ league: The SDV league key, e.g. "nfl".
268
+ season: One season, or one per point.
269
+ height: The wordmark height as a fraction of the Axes height, in (0, 1].
270
+ alpha: Opacity, 0 to 1.
271
+ variant: "default", "dark", or a named variant from ``marks()``.
272
+ zorder: matplotlib drawing order.
273
+ id_system: The id system of ``teams``; "auto" tries each in order.
274
+ transform: The coordinates x and y are in, when not the Axes' data: a Cartopy CRS such as
275
+ ``ccrs.PlateCarree()`` (longitude/latitude, required on a GeoAxes) or a matplotlib Transform.
276
+ A mark whose position falls outside the Axes is not drawn, whatever the transform.
277
+
278
+ Returns:
279
+ object: ``target`` itself, drawn on.
280
+
281
+ Raises:
282
+ InputError: (a ValueError) If ``height`` or ``alpha`` is out of range, ``league``, ``id_system`` or ``variant``
283
+ is unknown, or ``season`` is not a year or is outside the seasons sdvplot knows for the league.
284
+ ValueError: If the inputs differ in length, the target has several Axes, or the target is a Cartopy GeoAxes and
285
+ ``transform`` is None.
286
+ UnsupportedTargetError: (a TypeError) If ``target`` is not a matplotlib Axes, a Figure or a seaborn grid.
287
+ OfflineError: If the logo manifest or a mark's image is neither cached nor downloadable (a DownloadError, also
288
+ an OSError, when the CDN answers with an error status; an IntegrityError when it sends a file that does not
289
+ match the manifest's sha256, or one PIL cannot decode).
290
+ UnsafeDownloadError: (an OSError) If a download is refused: larger than the byte cap, past the deadline, or
291
+ redirected away from https.
292
+ UnsafeCachePathError: (a ValueError) If the manifest's sha256 or extension for a mark would put the file outside
293
+ the cache directory.
294
+ OptionalDependencyError: If a mark is an SVG and the ``svg`` extra is not installed.
295
+
296
+ Example:
297
+ ::
298
+
299
+ import matplotlib.pyplot as plt
300
+ import sdvplot
301
+
302
+ fig, ax = plt.subplots()
303
+ sdvplot.add_wordmarks(ax, [0.5], [0.5], ["KC"], league="nfl", height=0.1)
304
+
305
+ See Also:
306
+ sdvplotR geom_nfl_wordmarks(): https://sdvplotR.sportsdataverse.org/
307
+ """
308
+ return _add(
309
+ target, x, y, teams, kind="wordmark", league=league, season=season, height=height, alpha=alpha,
310
+ variant=variant, zorder=zorder, id_system=id_system, transform=transform,
311
+ ) # fmt: skip
312
+
313
+
314
+ def add_headshots(
315
+ target: Any,
316
+ x: Any,
317
+ y: Any,
318
+ players: Any,
319
+ *,
320
+ league: str,
321
+ height: float = 0.1,
322
+ alpha: float = 1,
323
+ zorder: float = 3,
324
+ id_system: str = "espn",
325
+ transform: Any = None,
326
+ ) -> Any:
327
+ """Draw each player's headshot centred on its (x, y) point of a matplotlib or seaborn plot.
328
+
329
+ Args:
330
+ target: A matplotlib Axes, a Figure with one Axes, or a seaborn grid with one Axes (or a JointGrid).
331
+ x: The points' x positions, in data coordinates (read by position).
332
+ y: The points' y positions, the same length as ``x``.
333
+ players: The player id for each point.
334
+ league: The SDV league key, e.g. "nfl".
335
+ height: The headshot height as a fraction of the Axes height, in (0, 1].
336
+ alpha: Opacity, 0 to 1.
337
+ zorder: matplotlib drawing order.
338
+ id_system: "espn" (ESPN athlete ids) or "gsis" (NFL), as in ``headshot_url``.
339
+ transform: The coordinates x and y are in, when not the Axes' data: a Cartopy CRS such as
340
+ ``ccrs.PlateCarree()`` (longitude/latitude, required on a GeoAxes) or a matplotlib Transform.
341
+ A mark whose position falls outside the Axes is not drawn, whatever the transform.
342
+
343
+ Returns:
344
+ object: ``target`` itself, drawn on.
345
+
346
+ Raises:
347
+ InputError: (a ValueError) If ``height`` or ``alpha`` is out of range, ``league`` has no ESPN headshots, or
348
+ ``id_system`` is not valid for ``league``.
349
+ ValueError: If the inputs differ in length, the target has several Axes, or the target is a Cartopy GeoAxes and
350
+ ``transform`` is None.
351
+ UnsupportedTargetError: (a TypeError) If ``target`` is not a matplotlib Axes, a Figure or a seaborn grid.
352
+ OfflineError: If a headshot, or with ``id_system="gsis"`` the nflverse player table, is neither cached nor
353
+ downloadable (a DownloadError, also an OSError, for an HTTP error status).
354
+ UnsafeDownloadError: (an OSError) If a download is refused: larger than the byte cap, past the deadline, or
355
+ redirected away from https.
356
+
357
+ Example:
358
+ ::
359
+
360
+ import matplotlib.pyplot as plt
361
+ import sdvplot
362
+
363
+ fig, ax = plt.subplots()
364
+ sdvplot.add_headshots(ax, [0.5], [0.5], ["3139477"], league="nfl", height=0.2)
365
+
366
+ See Also:
367
+ sdvplotR geom_nfl_headshots(): https://sdvplotR.sportsdataverse.org/
368
+ """
369
+ return _add(
370
+ target, x, y, players, kind="headshot", league=league, season=None, height=height, alpha=alpha,
371
+ variant="default", zorder=zorder, id_system=id_system, transform=transform,
372
+ ) # fmt: skip
373
+
374
+
375
+ def _read_images(placements: list[Placement], cache: dict[str, np.ndarray | None]) -> list[str]:
376
+ """Read each placement's image into ``cache`` (url -> RGBA array, or None when it cannot be read), each url once
377
+ across calls; return the urls of the points whose image cannot be read, one per point."""
378
+ for p in placements:
379
+ if p.url not in cache:
380
+ try:
381
+ cache[p.url] = _rgba_array(load_path_image(p.url))
382
+ except (OSError, ValueError, OfflineError): # missing file, not an image, failed download
383
+ cache[p.url] = None
384
+ return [p.url for p in placements if cache[p.url] is None]
385
+
386
+
387
+ def _draw_images(
388
+ ax: Axes,
389
+ placements: list[Placement],
390
+ *,
391
+ height: float,
392
+ alpha: float = 1.0,
393
+ zorder: float = 3,
394
+ xycoords: Any = "data",
395
+ cache: dict[str, np.ndarray | None] | None = None,
396
+ warn: bool = True,
397
+ ) -> list[AnnotationBbox]:
398
+ """``_draw_placements`` for ``place_images``: each image is read once; the points whose image cannot be read are
399
+ skipped with one SdvplotWarning. ``cache`` holds images already read (``_read_images``); ``warn=False`` skips
400
+ without warning, for a caller that already warned (plotnine reads and warns once per render, then draws panels)."""
401
+ cache = {} if cache is None else cache
402
+ unreadable = _read_images(placements, cache)
403
+ if warn:
404
+ _warn_skipped("whose image could not be read", unreadable)
405
+ images = {url: img for url, img in cache.items() if img is not None}
406
+ drawable = [p for p in placements if p.url in images]
407
+ return _draw_placements(ax, drawable, height=height, alpha=alpha, zorder=zorder, xycoords=xycoords, images=images)
408
+
409
+
410
+ def add_images(
411
+ target: Any,
412
+ x: Any,
413
+ y: Any,
414
+ paths: Any,
415
+ *,
416
+ height: float = 0.1,
417
+ alpha: float = 1,
418
+ zorder: float = 3,
419
+ transform: Any = None,
420
+ ) -> Any:
421
+ """Draw any image, by local path or URL, centred on each (x, y) point of a matplotlib or seaborn plot.
422
+
423
+ The image counterpart of ``add_logos``, with the same sizing: ``height`` is a fraction of the Axes height, and
424
+ each image keeps its aspect ratio. URLs are downloaded once and cached (like headshots); local files are read
425
+ as they are. PNG, JPEG, GIF, WebP and the other formats Pillow reads work; SVG does not.
426
+
427
+ Args:
428
+ target: A matplotlib Axes, a Figure with one Axes, or a seaborn grid with one Axes (or a JointGrid).
429
+ x: The points' x positions, in data coordinates (list, numpy array, or pandas/polars Series; read by position).
430
+ y: The points' y positions, the same length as ``x``.
431
+ paths: The image for each point (or one ``pathlib.Path`` for one point): a local path (str or
432
+ ``pathlib.Path``), a ``file://`` URI or an https URL (http is refused). A null path draws nothing.
433
+ height: The image height as a fraction of the Axes height, in (0, 1].
434
+ alpha: Opacity, 0 to 1.
435
+ zorder: matplotlib drawing order (3 draws above lines and markers).
436
+ transform: The coordinates x and y are in, when not the Axes' data: a Cartopy CRS (required on a GeoAxes) or
437
+ a matplotlib Transform such as ``ax.transAxes``.
438
+
439
+ Returns:
440
+ object: ``target`` itself, drawn on. Points whose image cannot be read (a missing file, a file that is not
441
+ an image, a failed download) or whose x or y is missing are skipped, with one SdvplotWarning per reason.
442
+
443
+ Raises:
444
+ InputError: (a ValueError) If ``height`` or ``alpha`` is out of range.
445
+ ValueError: If ``x``/``y``/``paths`` differ in length, the target has several Axes, or the target is a Cartopy
446
+ GeoAxes and ``transform`` is None. (An image that cannot be read or downloaded is skipped with a warning,
447
+ not raised.)
448
+ UnsupportedTargetError: (a TypeError) If ``target`` is not a matplotlib Axes, a Figure or a seaborn grid.
449
+
450
+ Example:
451
+ ::
452
+
453
+ import matplotlib.pyplot as plt
454
+ from sdvplot.matplotlib import add_images
455
+
456
+ fig, ax = plt.subplots()
457
+ ax.set_xlim(0, 10)
458
+ ax.set_ylim(0, 10)
459
+ add_images(ax, [3, 7], [5, 5], ["court.png", "https://www.python.org/static/img/python-logo.png"],
460
+ height=0.2)
461
+
462
+ See Also:
463
+ ggpath geom_from_path(): https://mrcaseb.github.io/ggpath/ ;
464
+ sdvplotR: https://sdvplotR.sportsdataverse.org/
465
+ """
466
+ h, a = check_height(height), check_alpha(alpha)
467
+ ax = _target_axes(target)
468
+ _draw_images(ax, place_images(x, y, paths), height=h, alpha=a, zorder=zorder, xycoords=_xycoords(ax, transform))
469
+ return target
470
+
471
+
472
+ def _axis(ax: Axes, axis: str) -> Any:
473
+ if axis not in ("x", "y"):
474
+ raise ValueError(f"axis must be 'x' or 'y', got {axis!r}")
475
+ return ax.xaxis if axis == "x" else ax.yaxis
476
+
477
+
478
+ def _ticks(which: Any) -> tuple[list[float], list[str]]:
479
+ locs = [float(v) for v in which.get_majorticklocs()]
480
+ return locs, [str(s) for s in which.get_major_formatter().format_ticks(locs)]
481
+
482
+
483
+ def _in_view(ax: Axes, axis: str) -> tuple[list[float], list[str]]:
484
+ """The positions and labels of the ticks of ``axis`` inside the view: the ones axis_logos turns into images."""
485
+ locs, labels = _ticks(_axis(ax, axis))
486
+ low, high = sorted(ax.get_xlim() if axis == "x" else ax.get_ylim())
487
+ in_view = [(loc, lab) for loc, lab in zip(locs, labels, strict=True) if low <= loc <= high]
488
+ return [loc for loc, _ in in_view], [lab for _, lab in in_view]
489
+
490
+
491
+ def axis_logos(
492
+ target: Any,
493
+ axis: str,
494
+ *,
495
+ league: str,
496
+ season: Any = None,
497
+ height: float = 0.1,
498
+ variant: str = "default",
499
+ mark_type: str = "logo",
500
+ id_system: str = "auto",
501
+ ) -> Any:
502
+ """Replace a team axis' tick labels with the teams' logos (or wordmarks).
503
+
504
+ Reads the axis' ticks and labels when called, so call it after setting the categories and limits. Labels that are
505
+ not teams stay as text, with one SdvplotWarning.
506
+
507
+ Args:
508
+ target: A matplotlib Axes, a Figure with one Axes, or a seaborn grid with one Axes.
509
+ axis: "x" or "y".
510
+ league: The SDV league key, e.g. "nfl".
511
+ season: One season for every label.
512
+ height: The image height as a fraction of the Axes height, in (0, 1].
513
+ variant: "default", "dark", or a named variant from ``marks()``.
514
+ mark_type: "logo" or "wordmark".
515
+ id_system: The id system of the labels; "auto" tries each in order.
516
+
517
+ Returns:
518
+ object: ``target`` itself, drawn on.
519
+
520
+ Raises:
521
+ InputError: (a ValueError) If ``height`` is out of range, ``league``, ``id_system``, ``mark_type`` or
522
+ ``variant`` is unknown, or ``season`` is not a year or is outside the seasons sdvplot knows for the league.
523
+ ValueError: If ``axis`` is not "x"/"y", or the target has several Axes.
524
+ UnsupportedTargetError: (a TypeError) If ``target`` is not a matplotlib Axes, a Figure or a seaborn grid.
525
+ OfflineError: If the logo manifest or a mark's image is neither cached nor downloadable (a DownloadError, also
526
+ an OSError, when the CDN answers with an error status; an IntegrityError when it sends a file that does not
527
+ match the manifest's sha256, or one PIL cannot decode).
528
+ UnsafeDownloadError: (an OSError) If a download is refused: larger than the byte cap, past the deadline, or
529
+ redirected away from https.
530
+ UnsafeCachePathError: (a ValueError) If the manifest's sha256 or extension for a mark would put the file outside
531
+ the cache directory.
532
+ OptionalDependencyError: If a mark is an SVG and the ``svg`` extra is not installed.
533
+
534
+ Example:
535
+ ::
536
+
537
+ import matplotlib.pyplot as plt
538
+ import sdvplot
539
+
540
+ fig, ax = plt.subplots()
541
+ ax.bar(["KC", "BUF", "BAL"], [12, 10, 9])
542
+ sdvplot.axis_logos(ax, "x", league="nfl", height=0.08)
543
+
544
+ See Also:
545
+ sdvplotR element_sdv_logo(): https://sdvplotR.sportsdataverse.org/
546
+ """
547
+ return _axis_logos(target, axis, league=league, season=season, height=height, variant=variant,
548
+ mark_type=mark_type, id_system=id_system) # fmt: skip
549
+
550
+
551
+ def _axis_logos(
552
+ target: Any,
553
+ axis: str,
554
+ *,
555
+ league: str,
556
+ season: Any = None,
557
+ height: float = 0.1,
558
+ variant: str = "default",
559
+ mark_type: str = "logo",
560
+ id_system: str = "auto",
561
+ warn: bool = True,
562
+ ) -> Any:
563
+ """axis_logos; ``warn=False`` skips the labels that are not teams without warning (plotnine warns once for every
564
+ panel's labels, then draws each panel quietly)."""
565
+ h = check_height(height)
566
+ ax = _target_axes(target)
567
+ which = _axis(ax, axis)
568
+ locs, labels = _ticks(which)
569
+ view_locs, view_labels = _in_view(ax, axis)
570
+ if axis == "x":
571
+ positions: tuple[list[Any], list[Any]] = (view_locs, [0.0] * len(view_locs))
572
+ else:
573
+ positions = ([0.0] * len(view_locs), view_locs)
574
+ placements = place(*positions, view_labels, league=league, season=season, kind=mark_type, variant=variant,
575
+ id_system=id_system, _warn=warn) # fmt: skip
576
+ drawn = {(p.x if axis == "x" else p.y) for p in placements}
577
+ which.set_major_locator(FixedLocator(locs))
578
+ which.set_major_formatter(
579
+ FixedFormatter(["" if loc in drawn else lab for loc, lab in zip(locs, labels, strict=True)])
580
+ )
581
+ tick = which.get_major_ticks()[0] if which.get_major_ticks() else None
582
+ offset = (tick.get_tick_padding() if tick is not None else 0) + 2 # images start this many points past the axis
583
+ image_points = h * ax.bbox.height * 72 / ax.figure.dpi
584
+ if tick is not None: # keep the configured label pad, plus room for the images below/left of the tick marks
585
+ which.set_tick_params(pad=tick.get_pad() + 2 + image_points)
586
+ images: dict[str, np.ndarray] = {}
587
+ for p in placements:
588
+ loc = p.x if axis == "x" else p.y
589
+ if p.url not in images:
590
+ images[p.url] = _image(p)
591
+ box = AnnotationBbox(
592
+ _AxesFractionImage(images[p.url], ax, h),
593
+ (loc, 0) if axis == "x" else (0, loc),
594
+ xycoords=ax.get_xaxis_transform() if axis == "x" else ax.get_yaxis_transform(),
595
+ xybox=(0, -offset) if axis == "x" else (-offset, 0),
596
+ boxcoords="offset points",
597
+ box_alignment=(0.5, 1.0) if axis == "x" else (1.0, 0.5),
598
+ frameon=False,
599
+ pad=0,
600
+ annotation_clip=False,
601
+ )
602
+ box._sdvplot_axis_mark = (axis, p.team_id, loc) # type: ignore[attr-defined]
603
+ ax.add_artist(box)
604
+ return target
605
+
606
+
607
+ def _align(ha: Any) -> float:
608
+ """A horizontal alignment as a fraction: 0 left, 0.5 centre, 1 right (plotnine also takes a number)."""
609
+ if isinstance(ha, numbers.Real):
610
+ return float(ha)
611
+ return _HA.get(ha, 0.5)
612
+
613
+
614
+ class _TitleImage(AnnotationBbox):
615
+ """An image beside a title Text, ``height`` points tall. sdvplotR puts the image inside the title, so the pair is
616
+ aligned as one: at each draw this shifts the text by the image's width (times the title's alignment) to match."""
617
+
618
+ def __init__(self, arr: np.ndarray, text: Text, side: str, height: float, align: Callable[[], float]) -> None:
619
+ left = side == "left"
620
+ super().__init__(
621
+ OffsetImage(arr, zoom=height / arr.shape[0]), # OffsetImage sizes in points: rows * zoom
622
+ (0.0, 0.5) if left else (1.0, 0.5),
623
+ xycoords=text, # a fraction of the text's bbox
624
+ xybox=(-_TITLE_GAP if left else _TITLE_GAP, 0),
625
+ boxcoords="offset points",
626
+ box_alignment=(1.0, 0.5) if left else (0.0, 0.5),
627
+ frameon=False,
628
+ pad=0,
629
+ annotation_clip=False,
630
+ zorder=text.get_zorder() - 0.01, # drawn just before the text, so the text draws with this draw's shift
631
+ )
632
+ self._sdv_text, self._sdv_left, self._sdv_align, self._sdv_shift = text, left, align, Affine2D()
633
+ self._sdv_base: Transform | None = None
634
+ self._sdv_shifted: Transform | None = None
635
+ self._shift_text()
636
+
637
+ def _shift_text(self) -> None:
638
+ """Compose the shift onto the text's transform, and again whenever something replaced that transform
639
+ (``Axes.set_title`` resets all three Axes titles' transforms on every call)."""
640
+ if self._sdv_text.get_transform() is not self._sdv_shifted:
641
+ self._sdv_base = self._sdv_text.get_transform()
642
+ self._sdv_shifted = self._sdv_base + self._sdv_shift
643
+ self._sdv_text.set_transform(self._sdv_shifted)
644
+
645
+ def update_positions(self, renderer: Any) -> None:
646
+ self._shift_text()
647
+ room = self.offsetbox.get_bbox(renderer).width + renderer.points_to_pixels(_TITLE_GAP)
648
+ rel = self._sdv_align()
649
+ self._sdv_shift.clear().translate((1 - rel) * room if self._sdv_left else -rel * room, 0)
650
+ super().update_positions(renderer)
651
+
652
+ def remove(self) -> None:
653
+ """Remove the image and give the title text back its unshifted transform."""
654
+ if self._sdv_base is not None and self._sdv_text.get_transform() is self._sdv_shifted:
655
+ self._sdv_text.set_transform(self._sdv_base)
656
+ super().remove()
657
+
658
+
659
+ def _check_title_image(side: Any, height: Any) -> float:
660
+ """``height`` as a float, or ValueError unless ``side`` is "left"/"right" (InputError unless ``height`` is a number
661
+ of points of at least 1)."""
662
+ if side not in ("left", "right"):
663
+ raise ValueError(f"side must be 'left' or 'right', got {side!r}")
664
+ if not _real(height) or height < 1:
665
+ raise InputError(f"height is the image height in points (1/72 inch), at least 1, got {height!r}")
666
+ return float(height)
667
+
668
+
669
+ def _title_source(image: Any, league: str | None, season: Any) -> tuple[np.ndarray, str] | None:
670
+ """The image to put beside a title, plus what it was: a team's logo when ``league`` is given (None, with one
671
+ SdvplotWarning, when the team does not resolve or has no logo; a failed download raises, as in add_logos), else
672
+ the image at a URL or local path (None, with one SdvplotWarning, when it cannot be read)."""
673
+ if league is not None:
674
+ img = logo_image(image, league, season=season, size=_MAX_IMAGE_HEIGHT) # a title image is points tall
675
+ return None if img is None else (_rgba_array(img), str(image))
676
+ source = str(image)
677
+ try:
678
+ return _rgba_array(load_path_image(source)), source
679
+ except (OSError, ValueError, OfflineError) as e: # missing file, not an image, failed download (as _read_images)
680
+ warn(f"title_image: could not read {source!r} ({e}); drawn without it")
681
+ return None
682
+
683
+
684
+ def _add_title_image(
685
+ container: Any,
686
+ text: Text,
687
+ source: tuple[np.ndarray, str] | None,
688
+ side: str,
689
+ height: float,
690
+ align: Callable[[], float],
691
+ ) -> AnnotationBbox | None:
692
+ """Draw ``source`` (from _title_source) beside ``text``, an Axes title or a Figure's suptitle/text, replacing any
693
+ image already beside that text; None draws nothing new."""
694
+ for old in [a for a in container.artists if isinstance(a, _TitleImage) and a._sdv_text is text]:
695
+ old.remove()
696
+ if source is None:
697
+ return None
698
+ box = _TitleImage(source[0], text, side, height, align)
699
+ box._sdvplot_title_image = (side, source[1]) # type: ignore[attr-defined]
700
+ container.add_artist(box)
701
+ return box
702
+
703
+
704
+ def title_image(
705
+ target: Any,
706
+ image: Any,
707
+ title: str = "",
708
+ *,
709
+ league: str | None = None,
710
+ season: Any = None,
711
+ side: str = "left",
712
+ height: float = 15,
713
+ **text_kw: Any,
714
+ ) -> Any:
715
+ """Set the plot title and draw an image (a team logo, or any image) beside it.
716
+
717
+ The title and the image are aligned together, like the image inside sdvplotR's title: a centred title centres the
718
+ pair, a left-aligned one starts with the image.
719
+
720
+ Args:
721
+ target: A matplotlib Axes (sets its title), a Figure (sets its suptitle), or a seaborn grid with one Axes.
722
+ image: A team, in any id system ``resolve()`` understands, when ``league`` is given; otherwise an image URL
723
+ (https; http is refused) or a local file path.
724
+ title: The title text.
725
+ league: The SDV league key, e.g. "nfl"; None reads ``image`` as a URL or path.
726
+ season: One season, to pick the team's logo for that era.
727
+ side: "left" or "right" of the title text.
728
+ height: The image height in points (1/72 inch), at any dpi. The title keeps its own line height, so an image
729
+ much taller than the text needs room: ``pad=`` on an Axes title, ``y=`` on a Figure's suptitle.
730
+ **text_kw: Passed to ``Axes.set_title`` (``loc``, ``fontsize``, ``pad``, ...) or ``Figure.suptitle``.
731
+
732
+ Returns:
733
+ object: ``target`` itself, titled. Calling it again on the same title replaces the image. An image by URL or
734
+ path that cannot be read gives one SdvplotWarning and the title without it.
735
+
736
+ Raises:
737
+ TypeError: If ``league`` is given and ``image`` is not one team.
738
+ InputError: (a ValueError) If ``height`` is not a number of points of at least 1, or ``league`` is given and it
739
+ is unknown or ``season`` is not a year or is outside the seasons sdvplot knows for the league.
740
+ ValueError: If ``side`` is not "left"/"right", or the target has several Axes.
741
+ UnsupportedTargetError: (a TypeError) If ``target`` is not a matplotlib Axes, a Figure or a seaborn grid.
742
+ OfflineError: If ``league`` is given and the team's logo (or the logo manifest) is neither cached nor
743
+ downloadable (a DownloadError, also an OSError, when the CDN answers with an error status; an IntegrityError
744
+ when it sends a file that does not match the manifest's sha256, or one PIL cannot decode). An image by URL
745
+ or path that cannot be read is skipped with a warning instead.
746
+ UnsafeDownloadError: (an OSError) If ``league`` is given and a download is refused: larger than the byte cap,
747
+ past the deadline, or redirected away from https.
748
+ UnsafeCachePathError: (a ValueError) If ``league`` is given and the manifest's sha256 or extension for the logo
749
+ would put the file outside the cache directory.
750
+ OptionalDependencyError: If ``league`` is given, the logo is an SVG and the ``svg`` extra is not installed.
751
+
752
+ Example:
753
+ ::
754
+
755
+ import matplotlib.pyplot as plt
756
+ from sdvplot.matplotlib import title_image
757
+
758
+ fig, ax = plt.subplots()
759
+ ax.plot([1, 2, 3], [3, 1, 2])
760
+ title_image(ax, "KC", "Kansas City Chiefs Analysis", league="nfl", height=20)
761
+
762
+ # A Figure's suptitle, the image on the right:
763
+ title_image(fig, "https://example.com/banner.png", "Week 1", side="right")
764
+
765
+ See Also:
766
+ sdvplotR ggtitle_image(): https://sdvplotR.sportsdataverse.org/reference/ggtitle_image.html ;
767
+ sdvplot.plotnine.title_image: the same for plotnine.
768
+ """
769
+ h = _check_title_image(side, height)
770
+ source = _title_source(image, league, season)
771
+ if isinstance(target, Figure):
772
+ container: Any = target
773
+ text = target.suptitle(title or " ", **text_kw) # a blank title still gives the image a line to sit on
774
+ else:
775
+ container = _target_axes(target)
776
+ text = container.set_title(title or " ", **text_kw)
777
+ _add_title_image(container, text, source, side, h, lambda: _align(text.get_horizontalalignment()))
778
+ return target
779
+
780
+
781
+ def team_tiers(
782
+ data: Any,
783
+ league: str,
784
+ *,
785
+ title: str | None = None,
786
+ subtitle: str | None = _tiers.SUBTITLE,
787
+ caption: str | None = None,
788
+ tier_desc: dict[Any, str] | None = None,
789
+ presort: bool = False,
790
+ alpha: float = 0.8,
791
+ height: float | None = None,
792
+ no_line_below_tier: Any = None,
793
+ devel: bool = False,
794
+ theme: Literal["dark", "light"] = "dark",
795
+ ) -> Figure:
796
+ """A tier list: each team's logo in its tier's row, tier 1 on top, on a dark (sdvplotR) or light theme.
797
+
798
+ Args:
799
+ data: A pandas or polars DataFrame with ``tier_no`` (1 is the top tier) and ``team`` (any id system
800
+ ``resolve()`` understands), and optionally ``tier_rank``, the position within the tier; without it, teams
801
+ keep their order in ``data``.
802
+ league: The SDV league key, e.g. "nfl".
803
+ title: The title; None gives "{LEAGUE} Team Tiers", "" none.
804
+ subtitle: The subtitle; None or "" for none.
805
+ caption: The caption, bottom right; None for none.
806
+ tier_desc: Each tier's label, keyed by tier number; None gives sdvplotR's (1 "Elite" ... 5 "What are they
807
+ doing?"). Labels wrap at 15 characters; a tier without one gets none.
808
+ presort: Sort teams alphabetically within each tier (ignores ``tier_rank``).
809
+ alpha: Logo opacity, 0 to 1.
810
+ height: Logo height as a fraction of the panel height; None gives 0.1, about the largest height at
811
+ 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
812
+ figure.
813
+ no_line_below_tier: A tier number, or several, with no separator line below.
814
+ devel: Draw each team as text instead of its logo (fast, and needs no download).
815
+ theme: "dark" (sdvplotR's: a near-black background) or "light" (white, for dark logos such as Ohio State's,
816
+ Texas A&M's or Penn State's, which vanish on dark).
817
+
818
+ Returns:
819
+ matplotlib.figure.Figure: A new figure with one Axes; a team that does not resolve is skipped with one
820
+ SdvplotWarning, keeping its slot.
821
+
822
+ Raises:
823
+ TypeError: If ``data`` is not a DataFrame, or ``tier_no``/``tier_rank`` hold non-numbers.
824
+ InputError: (a ValueError) If ``height``/``alpha`` is out of range, or ``league`` is unknown.
825
+ ValueError: If ``data`` lacks ``tier_no`` or ``team``, has no row with a tier, or ``theme`` is not "dark" or
826
+ "light".
827
+ OfflineError: Unless ``devel=True``, if the logo manifest or a mark's image is neither cached nor downloadable
828
+ (a DownloadError, also an OSError, when the CDN answers with an error status; an IntegrityError when it
829
+ sends a file that does not match the manifest's sha256, or one PIL cannot decode).
830
+ UnsafeDownloadError: (an OSError) Unless ``devel=True``, if a download is refused: larger than the byte cap,
831
+ past the deadline, or redirected away from https.
832
+ UnsafeCachePathError: (a ValueError) Unless ``devel=True``, if the manifest's sha256 or extension for a mark
833
+ would put the file outside the cache directory.
834
+ OptionalDependencyError: Unless ``devel=True``, if a logo is an SVG and the ``svg`` extra is not installed.
835
+
836
+ Example:
837
+ ::
838
+
839
+ import pandas as pd
840
+ from sdvplot.matplotlib import team_tiers
841
+
842
+ df = pd.DataFrame({"tier_no": [1, 1, 2, 3], "team": ["KC", "BUF", "BAL", "NYJ"]})
843
+ fig = team_tiers(df, "nfl")
844
+
845
+ # Draft it as text first, then add logos:
846
+ fig = team_tiers(df, "nfl", devel=True, no_line_below_tier=1)
847
+
848
+ # Dark logos on a white background:
849
+ fig = team_tiers(df, "cfb", theme="light")
850
+
851
+ See Also:
852
+ sdvplotR sdv_team_tiers(): https://sdvplotR.sportsdataverse.org/reference/sdv_team_tiers.html ;
853
+ sdvplot.plotnine.team_tiers: the same as a plotnine ggplot.
854
+ """
855
+ import matplotlib.pyplot as plt
856
+
857
+ t = _tiers.prepare(
858
+ data, league, title=title, subtitle=subtitle, caption=caption, tier_desc=tier_desc, presort=presort,
859
+ alpha=alpha, height=height, no_line_below_tier=no_line_below_tier, theme=theme,
860
+ ) # fmt: skip
861
+ fig, ax = plt.subplots(layout="constrained", facecolor=t.bg)
862
+ ax.set_facecolor(t.bg)
863
+ for y in t.lines:
864
+ ax.axhline(y, color=t.line_color, linewidth=0.8)
865
+ ax.set_xlim(t.xlim)
866
+ ax.set_ylim(t.ylim[1], t.ylim[0]) # tier 1 on top
867
+ ax.set_xticks([])
868
+ ax.set_yticks(t.breaks, t.break_labels, color=t.text, fontweight="bold")
869
+ ax.tick_params(length=0)
870
+ ax.spines[:].set_visible(False)
871
+ if devel:
872
+ for x, y, label in zip(t.x, t.y, t.labels, strict=True):
873
+ ax.text(x, y, label, color=t.text, ha="center", va="center")
874
+ else:
875
+ placements = place(t.x, t.y, t.team_ids, league=league, id_system="team_id")
876
+ _draw_placements(ax, placements, height=t.height, alpha=t.alpha)
877
+ anchor: Text | None = None # the subtitle sits on the panel, the title on the subtitle, both left-aligned
878
+ size = ax.title.get_fontproperties().get_size_in_points()
879
+ styles: list[tuple[str | None, dict[str, Any]]] = [
880
+ (t.subtitle, {"color": t.muted, "fontsize": size}),
881
+ (t.title, {"color": t.text, "fontweight": "bold", "fontsize": 1.2 * size}), # sdvplotR: rel(1.2)
882
+ ]
883
+ for text, style in styles:
884
+ if not text:
885
+ continue
886
+ if anchor is None:
887
+ anchor = ax.set_title(text, loc="left", **style)
888
+ else:
889
+ anchor = ax.annotate(text, (0, 1), xycoords=anchor, xytext=(0, 4), textcoords="offset points",
890
+ va="bottom", **style) # fmt: skip
891
+ if t.caption:
892
+ ax.annotate(t.caption, (1, 0), xycoords="axes fraction", xytext=(0, -6), textcoords="offset points",
893
+ ha="right", va="top", color=t.muted, fontsize="small") # fmt: skip
894
+ return fig
895
+
896
+
897
+ def _drawn_title_images(target: Any) -> list[tuple[str, str]]:
898
+ """Test hook: (side, image) for each title image on a Figure or an Axes, image being the team or URL/path given."""
899
+ container = target if isinstance(target, Figure) else _target_axes(target)
900
+ return [a._sdvplot_title_image for a in container.artists if hasattr(a, "_sdvplot_title_image")]
901
+
902
+
903
+ def _drawn_boxes(target: Any, tag: str) -> tuple[Axes, list[Any]]:
904
+ """The Axes and its sdvplot image boxes tagged ``tag``, after a draw. A layout engine (constrained, tight) resizes
905
+ the Axes on draw, so an image sized before that only shows the wrong fraction afterwards."""
906
+ ax = _target_axes(target)
907
+ ax.figure.canvas.draw()
908
+ return ax, [a for a in ax.artists if hasattr(a, tag)]
909
+
910
+
911
+ def _drawn_height(ax: Axes, box: Any) -> float:
912
+ """The height the box's image is drawn at, measured from its extent, as a fraction of the Axes height."""
913
+ return float(box.offsetbox.get_window_extent().height / ax.bbox.height)
914
+
915
+
916
+ def _drawn_marks(target: Any) -> list[tuple[Any, ...]]:
917
+ """Test hook: (team_id, x, y, height, url) for each image add_logos/add_wordmarks/add_headshots drew; height is
918
+ measured from the drawn image."""
919
+ ax, boxes = _drawn_boxes(target, "_sdvplot_mark")
920
+ return [(*b._sdvplot_mark[:3], _drawn_height(ax, b), b._sdvplot_mark[3]) for b in boxes]
921
+
922
+
923
+ def _drawn_axis_marks(target: Any, axis: str) -> list[tuple[str, float, float]]:
924
+ """Test hook: (team_id, tick position, height) for each axis image on ``axis``, in tick order; height is measured
925
+ from the drawn image."""
926
+ ax, boxes = _drawn_boxes(target, "_sdvplot_axis_mark")
927
+ marks = [(b._sdvplot_axis_mark, _drawn_height(ax, b)) for b in boxes]
928
+ return sorted(((team_id, loc, h) for (which, team_id, loc), h in marks if which == axis), key=lambda m: m[1])
929
+
930
+
931
+ def _visible_axis_labels(target: Any, axis: str) -> list[str]:
932
+ """Test hook: the tick labels on ``axis`` still shown as text."""
933
+ _, labels = _ticks(_axis(_target_axes(target), axis))
934
+ return [lab for lab in labels if lab]