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/plotly.py ADDED
@@ -0,0 +1,595 @@
1
+ """The Plotly adapter: logos, wordmarks, headshots and axis logos as layout images on a Plotly Figure.
2
+
3
+ A layout image takes one reference for both its position and its size, so an image placed in data coordinates is sized
4
+ in data units: its height is ``height`` times the y axis span. sdvplot pins the axis ranges (with room for the images,
5
+ which Plotly clips at the plot edge) so each image is exactly ``height`` of the plot area tall. Axis logos are placed in
6
+ paper coordinates, where ``height`` is exact as is.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime as dt
12
+ import math
13
+ import re
14
+ from typing import Any
15
+
16
+ from sdvplot._errors import UnsupportedTargetError, requires_extra, warn
17
+
18
+ with requires_extra("plotly"):
19
+ import plotly.graph_objects as go
20
+
21
+ from sdvplot._placement import check_alpha, check_height, place
22
+ from sdvplot._web import aspect, axis_letter, image_sources
23
+
24
+ _SUPPORTS_AXIS_LOGOS = True
25
+ _PLOTLY_DEFAULT_WIDTH, _PLOTLY_DEFAULT_HEIGHT = 700, 450 # plotly.js's figure size when layout.width/height are unset
26
+ _PLOTLY_DEFAULT_MARGIN = {"t": 100, "b": 80, "l": 80, "r": 80} # plotly.js's margins when unset
27
+ _REF = re.compile(r"^([xy])(\d*)$")
28
+ _READABLE = ("scatter", "scattergl", "bar") # trace types whose extent sdvplot can work out
29
+
30
+ __all__ = ["add_headshots", "add_logos", "add_wordmarks", "axis_logos"]
31
+
32
+
33
+ def __dir__() -> list[str]: # dir() and tab completion show the public API only
34
+ return list(__all__)
35
+
36
+
37
+ def _figure(target: Any) -> go.Figure:
38
+ if not isinstance(target, go.Figure): # FigureWidget subclasses Figure
39
+ raise UnsupportedTargetError(
40
+ f"sdvplot.plotly draws on a plotly.graph_objects.Figure, got {type(target).__name__}"
41
+ )
42
+ return target
43
+
44
+
45
+ def _margin(fig: go.Figure, side: str) -> float:
46
+ m = fig.layout.margin[side]
47
+ return float(_PLOTLY_DEFAULT_MARGIN[side] if m is None else m)
48
+
49
+
50
+ def _key(ref: str, letter: str) -> str:
51
+ """'y' -> 'yaxis', 'y2' -> 'yaxis2'."""
52
+ m = _REF.match(ref)
53
+ if m is None or m.group(1) != letter:
54
+ raise ValueError(f"{letter}ref must name a {letter} axis such as {letter!r} or '{letter}2', got {ref!r}")
55
+ return f"{letter}axis{m.group(2)}"
56
+
57
+
58
+ def _traces(fig: go.Figure, letter: str, ref: str) -> list[Any]:
59
+ return [t for t in fig.data if hasattr(t, f"{letter}axis") and (getattr(t, f"{letter}axis") or letter) == ref]
60
+
61
+
62
+ def _vals(trace: Any, letter: str) -> list[Any]:
63
+ """A trace's x or y values as a list (Plotly keeps numpy arrays as they are)."""
64
+ values = getattr(trace, letter)
65
+ return [] if values is None else [v for v in values if v is not None]
66
+
67
+
68
+ def _column(trace: Any, letter: str) -> list[Any]:
69
+ """A trace's x or y values point by point; without them, where Plotly draws it: x0 + i * dx (y0 + i * dy)."""
70
+ values = getattr(trace, letter)
71
+ if values is not None:
72
+ return list(values)
73
+ other = getattr(trace, "y" if letter == "x" else "x")
74
+ start, step = getattr(trace, f"{letter}0"), getattr(trace, f"d{letter}")
75
+ return [(start or 0) + i * (1 if step is None else step) for i in range(0 if other is None else len(other))]
76
+
77
+
78
+ def _unmeasurable(what: str, ref: str, letter: str) -> ValueError:
79
+ return ValueError(
80
+ f"cannot work out the {ref} range of {what}; set it first, "
81
+ f"e.g. fig.update_{letter}axes(range=[lo, hi]), then add the marks"
82
+ )
83
+
84
+
85
+ def _value_kind(v: Any) -> str:
86
+ if isinstance(v, str):
87
+ try:
88
+ float(v)
89
+ return "linear"
90
+ except ValueError:
91
+ pass
92
+ try:
93
+ dt.datetime.fromisoformat(v)
94
+ return "date"
95
+ except ValueError:
96
+ return "category"
97
+ if isinstance(v, dt.date) or type(v).__name__ in ("Timestamp", "datetime64"):
98
+ return "date"
99
+ return "linear"
100
+
101
+
102
+ def _axis_type(fig: go.Figure, letter: str, ref: str, values: list[Any]) -> str:
103
+ """The axis type: set explicitly, or inferred from the traces on the axis and the new values, as Plotly does."""
104
+ declared = fig.layout[_key(ref, letter)].type
105
+ if declared not in (None, "-"):
106
+ kind = str(declared)
107
+ else:
108
+ seen = [v for t in _traces(fig, letter, ref) for v in _vals(t, letter)]
109
+ kinds = {_value_kind(v) for v in [*seen, *values] if v is not None}
110
+ kind = "date" if "date" in kinds else "category" if "category" in kinds else "linear"
111
+ if kind not in ("linear", "category"):
112
+ raise ValueError(f"sdvplot does not support {kind} axes yet ({ref}); use a linear or category axis")
113
+ return kind
114
+
115
+
116
+ def _categories(fig: go.Figure, letter: str, ref: str) -> list[Any]:
117
+ """A category axis' categories in display order: categoryarray, or first appearance across the traces."""
118
+ axis = fig.layout[_key(ref, letter)]
119
+ order = axis.categoryorder or ("array" if axis.categoryarray is not None else "trace")
120
+ from_traces = list(dict.fromkeys(v for t in _traces(fig, letter, ref) for v in _vals(t, letter)))
121
+ if order == "array":
122
+ listed = list(axis.categoryarray or ())
123
+ return listed + [c for c in from_traces if c not in listed]
124
+ if order == "trace":
125
+ return from_traces
126
+ if order in ("category ascending", "category descending"):
127
+ return sorted(from_traces, reverse=order == "category descending")
128
+ raise ValueError(f"sdvplot does not support categoryorder={order!r} yet ({ref}); use 'trace' or 'array'")
129
+
130
+
131
+ def _extent(fig: go.Figure, letter: str, ref: str, coords: list[float], index: dict[Any, int] | None) -> list[float]:
132
+ """Every coordinate the axis' traces span, plus the new marks: the data Plotly's autorange would fit."""
133
+ out = list(coords)
134
+ across = "y" if letter == "x" else "x"
135
+ stacked = fig.layout.barmode in ("stack", "relative")
136
+ ends: dict[tuple[Any, ...], float] = {} # where each bar stack ends so far: Plotly stacks bars in trace order
137
+ filled: set[tuple[str, str]] = set() # subplots that already have a scatter trace, for fill="tonext..."
138
+ for t in _traces(fig, letter, ref):
139
+ if t.type not in _READABLE:
140
+ raise _unmeasurable(f"a {t.type} trace", ref, letter)
141
+ subplot = (t.xaxis or "x", t.yaxis or "y")
142
+ along = letter == ("x" if getattr(t, "orientation", None) == "h" else "y") # bar lengths and stacks run along
143
+ nums: list[float | None]
144
+ if index is not None and getattr(t, letter) is not None:
145
+ nums = [index.get(v) for v in _column(t, letter)]
146
+ else: # numbers; a trace without values sits at Plotly's default positions (on a category axis, as indices)
147
+ nums = [None if v is None else float(v) for v in _column(t, letter)]
148
+ if t.type == "bar" and along:
149
+ if t.base is not None or fig.layout.barnorm or (stacked and t.offsetgroup):
150
+ raise _unmeasurable("bars with a base, barnorm or stacked offsetgroups", ref, letter)
151
+ out.append(0.0) # bars start at zero
152
+ if stacked: # a bar starts where the stack at its position ends ("relative": one stack per sign)
153
+ for p, v in zip(_column(t, across), nums, strict=False):
154
+ if v is not None and not math.isnan(v):
155
+ key = (subplot, p, fig.layout.barmode == "relative" and v < 0)
156
+ ends[key] = ends.get(key, 0.0) + v
157
+ out.append(ends[key])
158
+ continue
159
+ out += [v for v in nums if v is not None]
160
+ if t.type == "bar" and index is None and not along: # a bar is as wide as the gap between bars
161
+ spots = sorted({v for v in nums if v is not None})
162
+ half = min((b - a for a, b in zip(spots, spots[1:], strict=False)), default=1.0) / 2
163
+ out += [spots[0] - half, spots[-1] + half] if spots else []
164
+ elif t.type != "bar":
165
+ if getattr(t, "stackgroup", None) and along:
166
+ raise _unmeasurable("stacked scatter traces (stackgroup)", ref, letter)
167
+ if t.fill == f"tozero{letter}" or (t.fill == f"tonext{letter}" and subplot not in filled):
168
+ out.append(0.0) # a filled area from zero; "tonext" fills to zero when no trace comes before it
169
+ filled.add(subplot)
170
+ if index is not None:
171
+ out += [-0.5, len(index) - 0.5] # a category axis shows every category's band
172
+ return [v for v in out if not math.isnan(v)]
173
+
174
+
175
+ def _range(fig: go.Figure, letter: str, ref: str, coords: list[float], index: dict[Any, int] | None, *,
176
+ mark: float) -> tuple[float, float]: # fmt: skip
177
+ """The axis range: as set, or the extent of the data with half a mark of room at each end, then pinned.
178
+
179
+ ``mark`` is the fraction of the axis length one mark covers.
180
+ """
181
+ axis = fig.layout[_key(ref, letter)]
182
+ if axis.range is not None and None not in axis.range and axis.autorange in (None, False):
183
+ return float(axis.range[0]), float(axis.range[1])
184
+ data = _extent(fig, letter, ref, coords, index)
185
+ lo, hi = (min(data), max(data)) if data else (0.0, 1.0)
186
+ span = (hi - lo or 1.0) / max(1 - mark, 0.5)
187
+ mid = (lo + hi) / 2
188
+ rng = (mid - span / 2, mid + span / 2)
189
+ if isinstance(axis.autorange, str) and "reversed" in axis.autorange:
190
+ rng = rng[::-1]
191
+ axis.update(range=list(rng), autorange=False)
192
+ return rng
193
+
194
+
195
+ def _plot_size(fig: go.Figure, xref: str, yref: str) -> tuple[float, float]:
196
+ """The subplot's size in pixels at the layout's width and height (plotly.js's defaults when unset)."""
197
+ w = (fig.layout.width or _PLOTLY_DEFAULT_WIDTH) - _margin(fig, "l") - _margin(fig, "r")
198
+ h = (fig.layout.height or _PLOTLY_DEFAULT_HEIGHT) - _margin(fig, "t") - _margin(fig, "b")
199
+ xd = fig.layout[_key(xref, "x")].domain or (0, 1)
200
+ yd = fig.layout[_key(yref, "y")].domain or (0, 1)
201
+ return w * (xd[1] - xd[0]), h * (yd[1] - yd[0])
202
+
203
+
204
+ def _coords(fig: go.Figure, letter: str, ref: str, values: list[Any]) -> tuple[list[Any], dict[Any, int] | None]:
205
+ """Values as axis coordinates: numbers as they are, category names as their index (None when not a category)."""
206
+ if _axis_type(fig, letter, ref, values) == "linear":
207
+ return list(values), None
208
+ index = {c: i for i, c in enumerate(_categories(fig, letter, ref))}
209
+ return [index.get(v) for v in values], index
210
+
211
+
212
+ def _add(
213
+ target: Any,
214
+ x: Any,
215
+ y: Any,
216
+ teams: Any,
217
+ *,
218
+ kind: str,
219
+ league: str,
220
+ season: Any,
221
+ height: float,
222
+ alpha: float,
223
+ variant: str,
224
+ xref: str,
225
+ yref: str,
226
+ layer: str,
227
+ embed: bool,
228
+ id_system: str,
229
+ ) -> Any:
230
+ h, a = check_height(height), check_alpha(alpha)
231
+ fig = _figure(target)
232
+ _key(xref, "x")
233
+ _key(yref, "y")
234
+ placements = place(x, y, teams, league=league, season=season, kind=kind, variant=variant, id_system=id_system)
235
+ xs, x_index = _coords(fig, "x", xref, [p.x for p in placements])
236
+ ys, y_index = _coords(fig, "y", yref, [p.y for p in placements])
237
+ keep = [i for i, (cx, cy) in enumerate(zip(xs, ys, strict=True)) if cx is not None and cy is not None]
238
+ if len(keep) < len(placements):
239
+ off = [p.team_id for i, p in enumerate(placements) if i not in keep]
240
+ warn(f"skipped {len(off)} point(s) whose category is not on the axis: {off}") # fmt: skip
241
+ placements, xs, ys = [placements[i] for i in keep], [xs[i] for i in keep], [ys[i] for i in keep]
242
+ if not placements:
243
+ return fig
244
+ plot_w, plot_h = _plot_size(fig, xref, yref)
245
+ x_lo, x_hi = _range(fig, "x", xref, xs, x_index, mark=h * max(aspect(p) for p in placements) * plot_h / plot_w)
246
+ y_lo, y_hi = _range(fig, "y", yref, ys, y_index, mark=h)
247
+ sizex = 2 * abs(x_hi - x_lo) # only bounds the width: "contain" fits each image by height
248
+ for p, src, cx, cy in zip(placements, image_sources(placements, embed=embed), xs, ys, strict=True):
249
+ fig.add_layout_image(
250
+ source=src, x=cx, y=cy, xref=xref, yref=yref, sizex=sizex, sizey=h * abs(y_hi - y_lo),
251
+ sizing="contain", xanchor="center", yanchor="middle", opacity=a, layer=layer,
252
+ name=f"sdvplot:{kind}:{p.team_id}",
253
+ ) # fmt: skip
254
+ return fig
255
+
256
+
257
+ def add_logos(
258
+ target: Any,
259
+ x: Any,
260
+ y: Any,
261
+ teams: Any,
262
+ *,
263
+ league: str,
264
+ season: Any = None,
265
+ height: float = 0.1,
266
+ alpha: float = 1,
267
+ variant: str = "default",
268
+ xref: str = "x",
269
+ yref: str = "y",
270
+ layer: str = "above",
271
+ embed: bool = False,
272
+ id_system: str = "auto",
273
+ ) -> Any:
274
+ """Draw each team's logo centred on its (x, y) point of a Plotly figure, as layout images.
275
+
276
+ Call it after adding the traces: an axis range that is not set is worked out from the scatter and bar traces
277
+ (stacked bars included) and the new points, with half a logo of room at each end (Plotly clips images at the plot
278
+ edge), and pinned, because a data-placed image is sized in axis units. The logos then zoom with the data. Linear
279
+ and category axes are supported (a category is placed at its index).
280
+
281
+ Args:
282
+ target: A ``plotly.graph_objects.Figure`` (or ``FigureWidget``).
283
+ x: The points' x positions in the axis' own values (numbers, or category names).
284
+ y: The points' y positions, the same length as ``x``.
285
+ teams: The team for each point, in any id system ``resolve()`` understands.
286
+ league: The SDV league key, e.g. "nfl".
287
+ season: One season, or one per point, to pick each team's mark for that era.
288
+ height: The logo height as a fraction of the plot area's height, in (0, 1].
289
+ alpha: Opacity, 0 to 1.
290
+ variant: "default", "dark", or a named variant from ``marks()``.
291
+ xref: The x axis to place on ("x", "x2", ...), for subplots.
292
+ yref: The y axis to place on ("y", "y2", ...).
293
+ layer: "above" or "below" the traces.
294
+ embed: Inline each image as a data URI (HTML that renders offline, and static export with kaleido) instead
295
+ of linking its URL.
296
+ id_system: The id system of ``teams``; "auto" tries each in order.
297
+
298
+ Returns:
299
+ object: ``target`` itself, with the images added (like Plotly's own ``add_*`` methods).
300
+
301
+ Raises:
302
+ InputError: (a ValueError) If ``height`` or ``alpha`` is out of range, ``league``, ``id_system`` or ``variant``
303
+ is unknown, or ``season`` is not a year or is outside the seasons sdvplot knows for the league.
304
+ ValueError: If the inputs differ in length, ``xref``/``yref`` does not name an x/y axis, an axis is a log or
305
+ date axis, or the range must be worked out from a trace type other than scatter or bar, stacked scatter
306
+ traces (``stackgroup``), or bars with a ``base``, ``barnorm`` or stacked ``offsetgroup`` (set the range
307
+ first).
308
+ UnsupportedTargetError: (a TypeError) If ``target`` is not a Plotly ``Figure``.
309
+ OfflineError: If the logo manifest, or with ``embed=True`` a mark's image, is neither cached nor downloadable (a
310
+ DownloadError, also an OSError, when the CDN answers with an error status; an IntegrityError when it sends a
311
+ file that does not match the manifest's sha256).
312
+ UnsafeDownloadError: (an OSError) If a download is refused: larger than the byte cap, past the deadline, or
313
+ redirected away from https.
314
+ UnsafeCachePathError: (a ValueError) If ``embed=True`` and the manifest's sha256 or extension for a mark would
315
+ put the file outside the cache directory.
316
+
317
+ Example:
318
+ ::
319
+
320
+ import plotly.graph_objects as go
321
+ import sdvplot
322
+
323
+ fig = go.Figure(go.Scatter(x=[10, 20], y=[-3, -7], mode="markers"))
324
+ sdvplot.add_logos(fig, [10, 20], [-3, -7], ["KC", "BUF"], league="nfl", height=0.15)
325
+
326
+ See Also:
327
+ sdvplotR geom_nfl_logos(): https://sdvplotR.sportsdataverse.org/ ;
328
+ Plotly layout images: https://plotly.com/python/images/
329
+ """
330
+ return _add(
331
+ target, x, y, teams, kind="logo", league=league, season=season, height=height, alpha=alpha,
332
+ variant=variant, xref=xref, yref=yref, layer=layer, embed=embed, id_system=id_system,
333
+ ) # fmt: skip
334
+
335
+
336
+ def add_wordmarks(
337
+ target: Any,
338
+ x: Any,
339
+ y: Any,
340
+ teams: Any,
341
+ *,
342
+ league: str,
343
+ season: Any = None,
344
+ height: float = 0.1,
345
+ alpha: float = 1,
346
+ variant: str = "default",
347
+ xref: str = "x",
348
+ yref: str = "y",
349
+ layer: str = "above",
350
+ embed: bool = False,
351
+ id_system: str = "auto",
352
+ ) -> Any:
353
+ """Draw each team's wordmark centred on its (x, y) point of a Plotly figure.
354
+
355
+ Args:
356
+ target: A ``plotly.graph_objects.Figure``.
357
+ x: The points' x positions in the axis' own values.
358
+ y: The points' y positions, the same length as ``x``.
359
+ teams: The team for each point, in any id system ``resolve()`` understands.
360
+ league: The SDV league key, e.g. "nfl".
361
+ season: One season, or one per point.
362
+ height: The wordmark height as a fraction of the plot area's height, in (0, 1].
363
+ alpha: Opacity, 0 to 1.
364
+ variant: "default", "dark", or a named variant from ``marks()``.
365
+ xref: The x axis to place on.
366
+ yref: The y axis to place on.
367
+ layer: "above" or "below" the traces.
368
+ embed: Inline each image as a data URI instead of linking its URL.
369
+ id_system: The id system of ``teams``; "auto" tries each in order.
370
+
371
+ Returns:
372
+ object: ``target`` itself, with the images added.
373
+
374
+ Raises:
375
+ InputError: (a ValueError) If ``height`` or ``alpha`` is out of range, ``league``, ``id_system`` or ``variant``
376
+ is unknown, or ``season`` is not a year or is outside the seasons sdvplot knows for the league.
377
+ ValueError: If the inputs differ in length, or an axis is not supported (see ``add_logos``).
378
+ UnsupportedTargetError: (a TypeError) If ``target`` is not a Plotly ``Figure``.
379
+ OfflineError: If the logo manifest, or with ``embed=True`` a mark's image, is neither cached nor downloadable (a
380
+ DownloadError, also an OSError, when the CDN answers with an error status; an IntegrityError when it sends a
381
+ file that does not match the manifest's sha256).
382
+ UnsafeDownloadError: (an OSError) If a download is refused: larger than the byte cap, past the deadline, or
383
+ redirected away from https.
384
+ UnsafeCachePathError: (a ValueError) If ``embed=True`` and the manifest's sha256 or extension for a mark would
385
+ put the file outside the cache directory.
386
+
387
+ Example:
388
+ ::
389
+
390
+ import plotly.graph_objects as go
391
+ import sdvplot
392
+
393
+ fig = go.Figure(go.Bar(x=["KC", "BUF"], y=[12, 10]))
394
+ sdvplot.add_wordmarks(fig, ["KC", "BUF"], [12, 10], ["KC", "BUF"], league="nfl", height=0.08)
395
+
396
+ See Also:
397
+ sdvplotR geom_nfl_wordmarks(): https://sdvplotR.sportsdataverse.org/
398
+ """
399
+ return _add(
400
+ target, x, y, teams, kind="wordmark", league=league, season=season, height=height, alpha=alpha,
401
+ variant=variant, xref=xref, yref=yref, layer=layer, embed=embed, id_system=id_system,
402
+ ) # fmt: skip
403
+
404
+
405
+ def add_headshots(
406
+ target: Any,
407
+ x: Any,
408
+ y: Any,
409
+ players: Any,
410
+ *,
411
+ league: str,
412
+ height: float = 0.1,
413
+ alpha: float = 1,
414
+ xref: str = "x",
415
+ yref: str = "y",
416
+ layer: str = "above",
417
+ embed: bool = False,
418
+ id_system: str = "espn",
419
+ ) -> Any:
420
+ """Draw each player's headshot centred on its (x, y) point of a Plotly figure.
421
+
422
+ Args:
423
+ target: A ``plotly.graph_objects.Figure``.
424
+ x: The points' x positions in the axis' own values.
425
+ y: The points' y positions, the same length as ``x``.
426
+ players: The player id for each point.
427
+ league: The SDV league key, e.g. "nfl".
428
+ height: The headshot height as a fraction of the plot area's height, in (0, 1].
429
+ alpha: Opacity, 0 to 1.
430
+ xref: The x axis to place on.
431
+ yref: The y axis to place on.
432
+ layer: "above" or "below" the traces.
433
+ embed: Inline each image as a data URI instead of linking its URL.
434
+ id_system: "espn" (ESPN athlete ids) or "gsis" (NFL), as in ``headshot_url``.
435
+
436
+ Returns:
437
+ object: ``target`` itself, with the images added.
438
+
439
+ Raises:
440
+ InputError: (a ValueError) If ``height`` or ``alpha`` is out of range, ``league`` has no ESPN headshots, or
441
+ ``id_system`` is not valid for ``league``.
442
+ ValueError: If the inputs differ in length, or an axis is not supported (see ``add_logos``).
443
+ UnsupportedTargetError: (a TypeError) If ``target`` is not a Plotly ``Figure``.
444
+ OfflineError: If the nflverse player table (``id_system="gsis"``), or with ``embed=True`` a headshot, is neither
445
+ cached nor downloadable (a DownloadError, also an OSError, for an HTTP error status).
446
+ UnsafeDownloadError: (an OSError) If a download is refused: larger than the byte cap, past the deadline, or
447
+ redirected away from https.
448
+
449
+ Example:
450
+ ::
451
+
452
+ import plotly.graph_objects as go
453
+ import sdvplot
454
+
455
+ fig = go.Figure(go.Scatter(x=[0.3, 0.7], y=[0.5, 0.5], mode="markers"))
456
+ sdvplot.add_headshots(fig, [0.3], [0.5], ["3139477"], league="nfl", height=0.2)
457
+
458
+ See Also:
459
+ sdvplotR geom_nfl_headshots(): https://sdvplotR.sportsdataverse.org/
460
+ """
461
+ return _add(
462
+ target, x, y, players, kind="headshot", league=league, season=None, height=height, alpha=alpha,
463
+ variant="default", xref=xref, yref=yref, layer=layer, embed=embed, id_system=id_system,
464
+ ) # fmt: skip
465
+
466
+
467
+ def axis_logos(
468
+ target: Any,
469
+ axis: str,
470
+ *,
471
+ league: str,
472
+ season: Any = None,
473
+ height: float = 0.1,
474
+ variant: str = "default",
475
+ mark_type: str = "logo",
476
+ embed: bool = False,
477
+ id_system: str = "auto",
478
+ ) -> Any:
479
+ """Replace a category axis' team labels with the teams' logos (or wordmarks).
480
+
481
+ The images sit in paper coordinates just outside the plot (under the x axis, left of the y axis), ``height`` of
482
+ the plot area tall; labels that are not teams stay as text, with one SdvplotWarning. The bottom (left) margin grows
483
+ to make room. Call it after adding the traces, so the axis has its categories.
484
+
485
+ Args:
486
+ target: A ``plotly.graph_objects.Figure`` whose ``axis`` is a category axis.
487
+ axis: "x" or "y" (the first x or y axis).
488
+ league: The SDV league key, e.g. "nfl".
489
+ season: One season for every label.
490
+ height: The image height as a fraction of the plot area's height, in (0, 1].
491
+ variant: "default", "dark", or a named variant from ``marks()``.
492
+ mark_type: "logo" or "wordmark".
493
+ embed: Inline each image as a data URI instead of linking its URL.
494
+ id_system: The id system of the labels; "auto" tries each in order.
495
+
496
+ Returns:
497
+ object: ``target`` itself, with the images added and the resolved labels blanked.
498
+
499
+ Raises:
500
+ InputError: (a ValueError) If ``height`` is out of range, ``league``, ``id_system``, ``mark_type`` or
501
+ ``variant`` is unknown, or ``season`` is not a year or is outside the seasons sdvplot knows for the league.
502
+ ValueError: If ``axis`` is not "x"/"y", or the axis is not a category axis.
503
+ UnsupportedTargetError: (a TypeError) If ``target`` is not a Plotly ``Figure``.
504
+ OfflineError: If the logo manifest, or with ``embed=True`` a mark's image, is neither cached nor downloadable (a
505
+ DownloadError, also an OSError, when the CDN answers with an error status; an IntegrityError when it sends a
506
+ file that does not match the manifest's sha256).
507
+ UnsafeDownloadError: (an OSError) If a download is refused: larger than the byte cap, past the deadline, or
508
+ redirected away from https.
509
+ UnsafeCachePathError: (a ValueError) If ``embed=True`` and the manifest's sha256 or extension for a mark would
510
+ put the file outside the cache directory.
511
+
512
+ Example:
513
+ ::
514
+
515
+ import plotly.graph_objects as go
516
+ import sdvplot
517
+
518
+ fig = go.Figure(go.Bar(x=["KC", "BUF", "BAL"], y=[12, 10, 9]))
519
+ sdvplot.axis_logos(fig, "x", league="nfl", height=0.1)
520
+
521
+ See Also:
522
+ sdvplotR element_sdv_logo(): https://sdvplotR.sportsdataverse.org/
523
+ """
524
+ letter = axis_letter(axis)
525
+ h = check_height(height)
526
+ fig = _figure(target)
527
+ if _axis_type(fig, letter, letter, []) != "category":
528
+ raise ValueError(f"axis_logos needs a category {letter} axis (team names on the axis)")
529
+ cats = _categories(fig, letter, letter)
530
+ labels = [str(c) for c in cats]
531
+ index: list[Any] = list(range(len(cats)))
532
+ across: list[Any] = [0.0] * len(cats)
533
+ xs, ys = (index, across) if letter == "x" else (across, index)
534
+ placements = place(xs, ys, labels, league=league, season=season, kind=mark_type, variant=variant,
535
+ id_system=id_system) # fmt: skip
536
+ ax = fig.layout[_key(letter, letter)]
537
+ drawn = {int(p.x if letter == "x" else p.y) for p in placements}
538
+ ax.update(tickmode="array", tickvals=cats, ticktext=["" if i in drawn else lab for i, lab in enumerate(labels)])
539
+ if not placements:
540
+ return fig
541
+ plot_h = _plot_size(fig, "x", "y")[1]
542
+ if letter == "x":
543
+ # make room under the plot; the plot area shrinks by what the margin grows, so the images do too
544
+ fig.layout.margin.b = _margin(fig, "b") + math.ceil(h * plot_h / (1 + h))
545
+ lo, hi = 0.0, 1.0
546
+ else:
547
+ lo, hi = _range(fig, "y", "y", [], {c: i for i, c in enumerate(cats)}, mark=0)
548
+ fig.layout.margin.l = _margin(fig, "l") + math.ceil(h * plot_h * max(aspect(p) for p in placements))
549
+ for p, src in zip(placements, image_sources(placements, embed=embed), strict=True):
550
+ loc = int(p.x if letter == "x" else p.y)
551
+ if letter == "x":
552
+ spec = {"x": loc, "y": 0, "xref": "x", "yref": "paper", "sizex": 2 * len(cats), "sizey": h,
553
+ "xanchor": "center", "yanchor": "top"} # fmt: skip
554
+ else:
555
+ spec = {"x": 0, "y": loc, "xref": "paper", "yref": "y", "sizex": 1, "sizey": h * abs(hi - lo),
556
+ "xanchor": "right", "yanchor": "middle"} # fmt: skip
557
+ fig.add_layout_image(source=src, sizing="contain", layer="above", name=f"sdvplot:axis:{letter}:{p.team_id}",
558
+ **spec) # fmt: skip
559
+ return fig
560
+
561
+
562
+ def _span(fig: go.Figure, ref: str) -> float:
563
+ rng = fig.layout[_key(ref, ref[0])].range
564
+ return abs(float(rng[1]) - float(rng[0]))
565
+
566
+
567
+ def _height(fig: go.Figure, im: Any) -> float:
568
+ """A layout image's emitted height as a fraction of the plot height: sizey over its y reference's span."""
569
+ return float(im.sizey) / (1.0 if im.yref == "paper" else _span(fig, im.yref))
570
+
571
+
572
+ def _drawn_marks(target: Any) -> list[tuple[Any, ...]]:
573
+ """Test hook: (team_id, x, y, height, source) for each image add_logos/add_wordmarks/add_headshots drew."""
574
+ out = []
575
+ for im in _figure(target).layout.images:
576
+ parts = (im.name or "").split(":", 2)
577
+ if parts[0] == "sdvplot" and parts[1] != "axis":
578
+ out.append((parts[2], im.x, im.y, _height(target, im), im.source))
579
+ return out
580
+
581
+
582
+ def _drawn_axis_marks(target: Any, axis: str) -> list[tuple[str, float, float]]:
583
+ """Test hook: (team_id, category index, height) for each image on ``axis``, in tick order."""
584
+ letter = axis_letter(axis)
585
+ marks = []
586
+ for im in _figure(target).layout.images:
587
+ parts = (im.name or "").split(":", 3)
588
+ if parts[:3] == ["sdvplot", "axis", letter]:
589
+ marks.append((parts[3], float(im.x if letter == "x" else im.y), _height(target, im)))
590
+ return sorted(marks, key=lambda m: m[1])
591
+
592
+
593
+ def _visible_axis_labels(target: Any, axis: str) -> list[str]:
594
+ """Test hook: the tick labels on ``axis`` still shown as text."""
595
+ return [t for t in (_figure(target).layout[_key(axis_letter(axis), axis)].ticktext or ()) if t]