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.
- sdvplot/__init__.py +68 -0
- sdvplot/_cache.py +484 -0
- sdvplot/_colors.py +206 -0
- sdvplot/_contrast.py +60 -0
- sdvplot/_court.py +110 -0
- sdvplot/_deprecate.py +39 -0
- sdvplot/_dispatch.py +342 -0
- sdvplot/_errors.py +93 -0
- sdvplot/_headshots.py +131 -0
- sdvplot/_images.py +292 -0
- sdvplot/_index.py +178 -0
- sdvplot/_lazy.py +15 -0
- sdvplot/_manifest.py +73 -0
- sdvplot/_marks.py +296 -0
- sdvplot/_normalize.py +82 -0
- sdvplot/_placement.py +168 -0
- sdvplot/_resolve.py +363 -0
- sdvplot/_surface.py +220 -0
- sdvplot/_tables.py +87 -0
- sdvplot/_tiers.py +135 -0
- sdvplot/_types.py +38 -0
- sdvplot/_versions.py +30 -0
- sdvplot/_web.py +61 -0
- sdvplot/altair.py +652 -0
- sdvplot/bokeh.py +323 -0
- sdvplot/data/INDEX_VERSION +1 -0
- sdvplot/data/aliases.parquet +0 -0
- sdvplot/data/teams.parquet +0 -0
- sdvplot/folium.py +316 -0
- sdvplot/great_tables/__init__.py +267 -0
- sdvplot/great_tables/_cells.py +1814 -0
- sdvplot/great_tables/_export.py +844 -0
- sdvplot/great_tables/_layout.py +1802 -0
- sdvplot/great_tables/_marks.py +798 -0
- sdvplot/great_tables/_themes.py +2134 -0
- sdvplot/holoviews.py +303 -0
- sdvplot/matplotlib.py +934 -0
- sdvplot/plotly.py +595 -0
- sdvplot/plotnine.py +1104 -0
- sdvplot/plottable.py +141 -0
- sdvplot/py.typed +0 -0
- sdvplot/pygal.py +416 -0
- sdvplot/reactable.py +488 -0
- sdvplot/testing.py +579 -0
- sdvplot/typing.py +21 -0
- sdvplot-0.1.0.dist-info/METADATA +188 -0
- sdvplot-0.1.0.dist-info/RECORD +49 -0
- sdvplot-0.1.0.dist-info/WHEEL +4 -0
- 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]
|