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/altair.py ADDED
@@ -0,0 +1,652 @@
1
+ """The Altair adapter: logos, wordmarks and headshots as a native Vega-Lite image layer.
2
+
3
+ An image's size is a mark-level pixel property, so a layer's images are ``height`` times the chart height (in
4
+ pixels) tall, each fitted by height (``aspect=True``) inside a box as wide as the widest mark. ``add_logos`` returns a
5
+ new ``LayerChart`` (Altair charts are immutable values); grammar users can layer ``logo_layer(...)`` themselves.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import math
12
+ import numbers
13
+ import re
14
+ from typing import Any
15
+
16
+ from sdvplot._errors import UnsupportedTargetError, requires_extra
17
+
18
+ with requires_extra("altair"):
19
+ import altair as alt
20
+
21
+ from sdvplot._placement import Placement, check_alpha, check_height, place
22
+ from sdvplot._web import aspect, axis_letter, image_sources
23
+
24
+ _SUPPORTS_AXIS_LOGOS = True
25
+ _VEGA_LITE_DEFAULT_HEIGHT = 300 # px: Vega-Lite's continuous view height when neither chart nor theme sets one
26
+ _AXIS_GAP = 6 # px between the axis line and the axis images (past Vega-Lite's 5 px ticks)
27
+ _VEGA_LITE_LABEL_PADDING = 2 # px: Vega-Lite's default axis labelPadding
28
+ _URL, _TEAM = "sdvplot_url", "sdvplot_team" # the image layer's own columns
29
+ _DISCRETE = ("nominal", "ordinal")
30
+ _SUBCHARTS = {"FacetChart": "spec", "RepeatChart": "spec", "HConcatChart": "hconcat[i]",
31
+ "VConcatChart": "vconcat[i]", "ConcatChart": "concat[i]"} # fmt: skip
32
+ _BLANKED = re.compile(r"^indexof\((\[.*?\]), datum\.label\) >= 0")
33
+
34
+ __all__ = ["add_headshots", "add_logos", "add_wordmarks", "axis_logos", "logo_layer"]
35
+
36
+
37
+ def __dir__() -> list[str]: # dir() and tab completion show the public API only
38
+ return list(__all__)
39
+
40
+
41
+ def _jsonable(v: Any) -> Any:
42
+ """A position value Vega-Lite can read from inline data: numbers as numbers, dates as ISO strings."""
43
+ if hasattr(v, "item") and not hasattr(v, "isoformat"):
44
+ v = v.item() # a numpy scalar
45
+ return v.isoformat() if hasattr(v, "isoformat") else v
46
+
47
+
48
+ def _key(field: str) -> str:
49
+ """A Vega-Lite field name ("a\\.b") as its data column ("a.b")."""
50
+ return re.sub(r"\\(.)", r"\1", field)
51
+
52
+
53
+ def _check_chart(chart: Any) -> None:
54
+ name = type(chart).__name__
55
+ if name in _SUBCHARTS:
56
+ raise ValueError(
57
+ f"sdvplot cannot draw on a {name}; draw on one of its charts (chart.{_SUBCHARTS[name]}), then combine"
58
+ )
59
+ if not isinstance(chart, (alt.Chart, alt.LayerChart)):
60
+ raise UnsupportedTargetError(f"sdvplot.altair draws on an altair Chart or LayerChart, got {name}")
61
+
62
+
63
+ def _units(spec: dict[str, Any]) -> list[dict[str, Any]]:
64
+ """A chart spec and every layer inside it, outermost first."""
65
+ out = [spec]
66
+ for sub in spec.get("layer", []):
67
+ out += _units(sub)
68
+ return out
69
+
70
+
71
+ def _unit(spec: dict[str, Any], channel: str) -> tuple[dict[str, Any], dict[str, Any]]:
72
+ """The first (unit spec, channel definition) that encodes ``channel`` with a field; ({}, {}) when none does."""
73
+ for u in _units(spec):
74
+ enc = u.get("encoding", {}).get(channel)
75
+ if isinstance(enc, dict) and "field" in enc:
76
+ return u, enc
77
+ return {}, {}
78
+
79
+
80
+ def _pixels(h: Any, name: str) -> float:
81
+ """``h`` as a float, or ValueError unless it is a finite number of pixels above zero."""
82
+ if not isinstance(h, numbers.Real) or isinstance(h, bool) or not math.isfinite(h) or h <= 0:
83
+ raise ValueError(
84
+ f"sdvplot sizes marks from the chart height; {name} must be a positive number of pixels, not {h!r}"
85
+ )
86
+ return float(h)
87
+
88
+
89
+ def _chart_height(spec: dict[str, Any]) -> float:
90
+ """The chart's plot height in pixels: its own, else the theme's (or Vega-Lite's) continuous view height."""
91
+ for u in _units(spec):
92
+ h = u.get("height")
93
+ if h is not None:
94
+ return _pixels(h, ".properties(height=...)")
95
+ if _unit(spec, "y")[1].get("type") in _DISCRETE:
96
+ raise ValueError(
97
+ "a discrete y axis is sized by its step; set the chart height in pixels: .properties(height=...)"
98
+ )
99
+ h = spec.get("config", {}).get("view", {}).get("continuousHeight", _VEGA_LITE_DEFAULT_HEIGHT)
100
+ return _pixels(h, "config.view.continuousHeight")
101
+
102
+
103
+ def _sort(enc: dict[str, Any], channel: str) -> Any:
104
+ """The discrete axis' sort, when Vega-Lite keeps it once another layer shares the scale (it drops the rest)."""
105
+ sort = enc.get("sort", "ascending")
106
+ kept = (
107
+ sort is None
108
+ or sort in ("ascending", "descending")
109
+ or isinstance(sort, list)
110
+ or (isinstance(sort, dict) and "field" in sort and sort.get("op") in ("count", "min", "max"))
111
+ )
112
+ if not kept:
113
+ raise ValueError(
114
+ f"Vega-Lite drops the {channel} sort {sort!r} once a layer is added; sort with an explicit list "
115
+ "(sort=[...]) or by a field with op 'count', 'min' or 'max', then add the marks"
116
+ )
117
+ return sort
118
+
119
+
120
+ def _encodings(spec: dict[str, Any]) -> dict[str, dict[str, Any]]:
121
+ """The x and y encodings the image layer copies from the chart: field name, type, time unit and (discrete) sort.
122
+
123
+ A time unit is copied (the layer applies it to its own positions, as the chart does); an aggregate or a bin is
124
+ not, as it would be computed over the layer's few rows, so it raises.
125
+ """
126
+ out = {}
127
+ for ch in ("x", "y"):
128
+ enc = _unit(spec, ch)[1]
129
+ for key in ("aggregate", "bin"):
130
+ if enc.get(key) not in (None, False, "binned"):
131
+ raise ValueError(
132
+ f"the chart's {ch} encoding has {key}={enc[key]!r}, which the logo layer cannot copy; compute it "
133
+ "in the data first (e.g. with pandas), encode the result, then add the marks"
134
+ )
135
+ e: dict[str, Any] = {"field": enc.get("field", ch), "type": enc.get("type", "quantitative")}
136
+ if "timeUnit" in enc:
137
+ e["timeUnit"] = enc["timeUnit"]
138
+ if e["type"] in _DISCRETE and "sort" in enc:
139
+ e["sort"] = _sort(enc, ch)
140
+ out[ch] = e
141
+ return out
142
+
143
+
144
+ def _layer(
145
+ placements: list[Placement],
146
+ *,
147
+ kind: str,
148
+ height: float,
149
+ chart_height: float,
150
+ alpha: float,
151
+ x: dict[str, Any],
152
+ y: dict[str, Any],
153
+ embed: bool,
154
+ ) -> alt.Chart:
155
+ h_px = height * chart_height
156
+ rows = [
157
+ {_key(x["field"]): _jsonable(p.x), _key(y["field"]): _jsonable(p.y), _URL: src, _TEAM: p.team_id}
158
+ for p, src in zip(placements, image_sources(placements, embed=embed), strict=True)
159
+ ]
160
+ widest = max((aspect(p) for p in placements), default=1.0)
161
+ return (
162
+ alt.Chart(alt.Data(values=rows), name=f"sdvplot_{kind}")
163
+ .mark_image(width=h_px * widest, height=h_px, aspect=True, opacity=alpha)
164
+ .encode(x=alt.X(**x), y=alt.Y(**y), url=alt.Url(_URL, type="nominal"))
165
+ )
166
+
167
+
168
+ def logo_layer(
169
+ x: Any,
170
+ y: Any,
171
+ teams: Any,
172
+ *,
173
+ league: str,
174
+ season: Any = None,
175
+ height: float = 0.1,
176
+ chart_height: float | None = None,
177
+ alpha: float = 1,
178
+ variant: str = "default",
179
+ x_type: str = "quantitative",
180
+ y_type: str = "quantitative",
181
+ embed: bool = False,
182
+ id_system: str = "auto",
183
+ ) -> alt.Chart:
184
+ """A Vega-Lite image layer of team logos, to layer onto a chart: ``alt.layer(chart, logo_layer(...))``.
185
+
186
+ The layer encodes its own columns named ``x`` and ``y``, so give your chart explicit axis titles (an explicit title
187
+ wins over the layer's), or use ``add_logos``, which reuses the chart's own field names, types and sort.
188
+
189
+ Args:
190
+ x: The points' x positions, in the chart's x values (numbers, category names or dates).
191
+ y: The points' y positions, the same length as ``x``.
192
+ teams: The team for each point, in any id system ``resolve()`` understands.
193
+ league: The SDV league key, e.g. "nfl".
194
+ season: One season, or one per point, to pick each team's mark for that era.
195
+ height: The logo height as a fraction of the chart height, in (0, 1].
196
+ chart_height: The chart's height in pixels (a positive number); None means Vega-Lite's default (300).
197
+ alpha: Opacity, 0 to 1.
198
+ variant: "default", "dark", or a named variant from ``marks()``.
199
+ x_type: The Vega-Lite type of the chart's x axis ("quantitative", "nominal", "ordinal", "temporal").
200
+ y_type: The Vega-Lite type of the chart's y axis.
201
+ embed: Inline each image as a data URI (HTML that renders offline, and PNG export with vl-convert) instead of
202
+ linking its URL.
203
+ id_system: The id system of ``teams``; "auto" tries each in order.
204
+
205
+ Returns:
206
+ altair.Chart: The image layer.
207
+
208
+ Raises:
209
+ InputError: (a ValueError) If ``height`` or ``alpha`` is out of range, ``league``, ``id_system`` or ``variant``
210
+ is unknown, or ``season`` is not a year or is outside the seasons sdvplot knows for the league.
211
+ ValueError: If ``chart_height`` is not a positive number of pixels, or the inputs differ in length.
212
+ OfflineError: If the logo manifest, or with ``embed=True`` a mark's image, is neither cached nor downloadable (a
213
+ DownloadError, also an OSError, when the CDN answers with an error status; an IntegrityError when it sends a
214
+ file that does not match the manifest's sha256).
215
+ UnsafeDownloadError: (an OSError) If a download is refused: larger than the byte cap, past the deadline, or
216
+ redirected away from https.
217
+ UnsafeCachePathError: (a ValueError) If ``embed=True`` and the manifest's sha256 or extension for a mark would
218
+ put the file outside the cache directory.
219
+
220
+ Example:
221
+ ::
222
+
223
+ import altair as alt
224
+ import pandas as pd
225
+ import sdvplot.altair as salt
226
+
227
+ df = pd.DataFrame({"x": [10, 20], "y": [-3, -7], "team": ["KC", "BUF"]})
228
+ points = alt.Chart(df).mark_point().encode(x=alt.X("x", title="EPA"), y=alt.Y("y", title="Success"))
229
+ alt.layer(points, salt.logo_layer(df["x"], df["y"], df["team"], league="nfl", height=0.12))
230
+
231
+ See Also:
232
+ sdvplotR geom_nfl_logos(): https://sdvplotR.sportsdataverse.org/ ;
233
+ Altair image marks: https://altair-viz.github.io/user_guide/marks/image.html
234
+ """
235
+ h, a = check_height(height), check_alpha(alpha)
236
+ placements = place(x, y, teams, league=league, season=season, kind="logo", variant=variant, id_system=id_system)
237
+ return _layer(
238
+ placements, kind="logo", height=h, alpha=a,
239
+ chart_height=_VEGA_LITE_DEFAULT_HEIGHT if chart_height is None else _pixels(chart_height, "chart_height"),
240
+ x={"field": "x", "type": x_type}, y={"field": "y", "type": y_type}, embed=embed,
241
+ ) # fmt: skip
242
+
243
+
244
+ def _add(
245
+ chart: Any,
246
+ x: Any,
247
+ y: Any,
248
+ teams: Any,
249
+ *,
250
+ kind: str,
251
+ league: str,
252
+ season: Any,
253
+ height: float,
254
+ alpha: float,
255
+ variant: str,
256
+ embed: bool,
257
+ id_system: str,
258
+ ) -> alt.LayerChart:
259
+ h, a = check_height(height), check_alpha(alpha)
260
+ _check_chart(chart)
261
+ spec = chart.to_dict()
262
+ enc = _encodings(spec)
263
+ placements = place(x, y, teams, league=league, season=season, kind=kind, variant=variant, id_system=id_system)
264
+ layer = _layer(placements, kind=kind, height=h, chart_height=_chart_height(spec), alpha=a, x=enc["x"], y=enc["y"],
265
+ embed=embed) # fmt: skip
266
+ return chart + layer
267
+
268
+
269
+ def add_logos(
270
+ chart: Any,
271
+ x: Any,
272
+ y: Any,
273
+ teams: Any,
274
+ *,
275
+ league: str,
276
+ season: Any = None,
277
+ height: float = 0.1,
278
+ alpha: float = 1,
279
+ variant: str = "default",
280
+ embed: bool = False,
281
+ id_system: str = "auto",
282
+ ) -> alt.LayerChart:
283
+ """Layer each team's logo, centred on its (x, y) point, onto an Altair chart.
284
+
285
+ The image layer reuses the chart's x/y field names, types, time units and sort, so a nominal axis stays nominal,
286
+ a logo on ``yearmonth(date)`` sits on its month, and the axis titles stay as they were. An aggregated or binned
287
+ axis is not copied: aggregate or bin in the data first.
288
+
289
+ Args:
290
+ chart: An ``altair.Chart`` or ``LayerChart`` (facet, concat and repeat charts: pass one of their charts).
291
+ x: The points' x positions, in the chart's x values.
292
+ y: The points' y positions, the same length as ``x``.
293
+ teams: The team for each point, in any id system ``resolve()`` understands.
294
+ league: The SDV league key, e.g. "nfl".
295
+ season: One season, or one per point, to pick each team's mark for that era.
296
+ height: The logo height as a fraction of the chart height, in (0, 1].
297
+ alpha: Opacity, 0 to 1.
298
+ variant: "default", "dark", or a named variant from ``marks()``.
299
+ embed: Inline each image as a data URI instead of linking its URL.
300
+ id_system: The id system of ``teams``; "auto" tries each in order.
301
+
302
+ Returns:
303
+ altair.LayerChart: A new chart, ``chart`` plus the image layer (``chart`` itself is unchanged).
304
+
305
+ Raises:
306
+ InputError: (a ValueError) If ``height`` or ``alpha`` is out of range, ``league``, ``id_system`` or ``variant``
307
+ is unknown, or ``season`` is not a year or is outside the seasons sdvplot knows for the league.
308
+ ValueError: If the inputs differ in length, the chart is a facet, concat or repeat chart, its height is not a
309
+ positive number of pixels where it must be, its x or y encoding aggregates or bins, or a discrete axis is
310
+ sorted in a way Vega-Lite drops once layers share the axis.
311
+ UnsupportedTargetError: (a TypeError) If ``chart`` is not an Altair ``Chart`` or ``LayerChart``.
312
+ OfflineError: If the logo manifest, or with ``embed=True`` a mark's image, is neither cached nor downloadable (a
313
+ DownloadError, also an OSError, when the CDN answers with an error status; an IntegrityError when it sends a
314
+ file that does not match the manifest's sha256).
315
+ UnsafeDownloadError: (an OSError) If a download is refused: larger than the byte cap, past the deadline, or
316
+ redirected away from https.
317
+ UnsafeCachePathError: (a ValueError) If ``embed=True`` and the manifest's sha256 or extension for a mark would
318
+ put the file outside the cache directory.
319
+
320
+ Example:
321
+ ::
322
+
323
+ import altair as alt
324
+ import pandas as pd
325
+ import sdvplot
326
+
327
+ df = pd.DataFrame({"epa": [0.2, 0.1], "sr": [0.48, 0.45], "team": ["KC", "BUF"]})
328
+ chart = alt.Chart(df).mark_point().encode(x="epa", y="sr")
329
+ chart = sdvplot.add_logos(chart, df["epa"], df["sr"], df["team"], league="nfl", height=0.12)
330
+
331
+ See Also:
332
+ sdvplotR geom_nfl_logos(): https://sdvplotR.sportsdataverse.org/
333
+ """
334
+ return _add(
335
+ chart, x, y, teams, kind="logo", league=league, season=season, height=height, alpha=alpha,
336
+ variant=variant, embed=embed, id_system=id_system,
337
+ ) # fmt: skip
338
+
339
+
340
+ def add_wordmarks(
341
+ chart: Any,
342
+ x: Any,
343
+ y: Any,
344
+ teams: Any,
345
+ *,
346
+ league: str,
347
+ season: Any = None,
348
+ height: float = 0.1,
349
+ alpha: float = 1,
350
+ variant: str = "default",
351
+ embed: bool = False,
352
+ id_system: str = "auto",
353
+ ) -> alt.LayerChart:
354
+ """Layer each team's wordmark, centred on its (x, y) point, onto an Altair chart.
355
+
356
+ Args:
357
+ chart: An ``altair.Chart`` or ``LayerChart``.
358
+ x: The points' x positions, in the chart's x values.
359
+ y: The points' y positions, the same length as ``x``.
360
+ teams: The team for each point, in any id system ``resolve()`` understands.
361
+ league: The SDV league key, e.g. "nfl".
362
+ season: One season, or one per point.
363
+ height: The wordmark height as a fraction of the chart height, in (0, 1].
364
+ alpha: Opacity, 0 to 1.
365
+ variant: "default", "dark", or a named variant from ``marks()``.
366
+ embed: Inline each image as a data URI instead of linking its URL.
367
+ id_system: The id system of ``teams``; "auto" tries each in order.
368
+
369
+ Returns:
370
+ altair.LayerChart: A new chart, ``chart`` plus the image layer.
371
+
372
+ Raises:
373
+ InputError: (a ValueError) If ``height`` or ``alpha`` is out of range, ``league``, ``id_system`` or ``variant``
374
+ is unknown, or ``season`` is not a year or is outside the seasons sdvplot knows for the league.
375
+ ValueError: If the inputs differ in length, or the chart cannot take a layer (see ``add_logos``).
376
+ UnsupportedTargetError: (a TypeError) If ``chart`` is not an Altair ``Chart`` or ``LayerChart``.
377
+ OfflineError: If the logo manifest, or with ``embed=True`` a mark's image, is neither cached nor downloadable (a
378
+ DownloadError, also an OSError, when the CDN answers with an error status; an IntegrityError when it sends a
379
+ file that does not match the manifest's sha256).
380
+ UnsafeDownloadError: (an OSError) If a download is refused: larger than the byte cap, past the deadline, or
381
+ redirected away from https.
382
+ UnsafeCachePathError: (a ValueError) If ``embed=True`` and the manifest's sha256 or extension for a mark would
383
+ put the file outside the cache directory.
384
+
385
+ Example:
386
+ ::
387
+
388
+ import altair as alt
389
+ import pandas as pd
390
+ import sdvplot
391
+
392
+ df = pd.DataFrame({"team": ["KC", "BUF"], "wins": [12, 10]})
393
+ bars = alt.Chart(df).mark_bar().encode(x=alt.X("team", sort=None), y="wins")
394
+ sdvplot.add_wordmarks(bars, df["team"], df["wins"], df["team"], league="nfl", height=0.06)
395
+
396
+ See Also:
397
+ sdvplotR geom_nfl_wordmarks(): https://sdvplotR.sportsdataverse.org/
398
+ """
399
+ return _add(
400
+ chart, x, y, teams, kind="wordmark", league=league, season=season, height=height, alpha=alpha,
401
+ variant=variant, embed=embed, id_system=id_system,
402
+ ) # fmt: skip
403
+
404
+
405
+ def add_headshots(
406
+ chart: Any,
407
+ x: Any,
408
+ y: Any,
409
+ players: Any,
410
+ *,
411
+ league: str,
412
+ height: float = 0.1,
413
+ alpha: float = 1,
414
+ embed: bool = False,
415
+ id_system: str = "espn",
416
+ ) -> alt.LayerChart:
417
+ """Layer each player's headshot, centred on its (x, y) point, onto an Altair chart.
418
+
419
+ Args:
420
+ chart: An ``altair.Chart`` or ``LayerChart``.
421
+ x: The points' x positions, in the chart's x values.
422
+ y: The points' y positions, the same length as ``x``.
423
+ players: The player id for each point.
424
+ league: The SDV league key, e.g. "nfl".
425
+ height: The headshot height as a fraction of the chart height, in (0, 1].
426
+ alpha: Opacity, 0 to 1.
427
+ embed: Inline each image as a data URI instead of linking its URL.
428
+ id_system: "espn" (ESPN athlete ids) or "gsis" (NFL), as in ``headshot_url``.
429
+
430
+ Returns:
431
+ altair.LayerChart: A new chart, ``chart`` plus the image layer.
432
+
433
+ Raises:
434
+ InputError: (a ValueError) If ``height`` or ``alpha`` is out of range, ``league`` has no ESPN headshots, or
435
+ ``id_system`` is not valid for ``league``.
436
+ ValueError: If the inputs differ in length, or the chart cannot take a layer (see ``add_logos``).
437
+ UnsupportedTargetError: (a TypeError) If ``chart`` is not an Altair ``Chart`` or ``LayerChart``.
438
+ OfflineError: If the nflverse player table (``id_system="gsis"``), or with ``embed=True`` a headshot, is neither
439
+ cached nor downloadable (a DownloadError, also an OSError, for an HTTP error status).
440
+ UnsafeDownloadError: (an OSError) If a download is refused: larger than the byte cap, past the deadline, or
441
+ redirected away from https.
442
+
443
+ Example:
444
+ ::
445
+
446
+ import altair as alt
447
+ import pandas as pd
448
+ import sdvplot
449
+
450
+ df = pd.DataFrame({"x": [0.3], "y": [0.5], "player": ["3139477"]})
451
+ chart = alt.Chart(df).mark_point().encode(x="x", y="y")
452
+ sdvplot.add_headshots(chart, df["x"], df["y"], df["player"], league="nfl", height=0.2)
453
+
454
+ See Also:
455
+ sdvplotR geom_nfl_headshots(): https://sdvplotR.sportsdataverse.org/
456
+ """
457
+ return _add(
458
+ chart, x, y, players, kind="headshot", league=league, season=None, height=height, alpha=alpha,
459
+ variant="default", embed=embed, id_system=id_system,
460
+ ) # fmt: skip
461
+
462
+
463
+ def _categories(spec: dict[str, Any], unit: dict[str, Any], enc: dict[str, Any]) -> list[Any]:
464
+ """A discrete axis' categories in display order, from its explicit domain or sort list, or the inline data."""
465
+ domain = enc.get("scale", {}).get("domain")
466
+ if isinstance(domain, list):
467
+ return domain
468
+ sort = enc.get("sort", "ascending")
469
+ data = unit.get("data") or spec.get("data") or {}
470
+ rows = spec.get("datasets", {}).get(data.get("name"), data.get("values"))
471
+ if rows is None:
472
+ if isinstance(sort, list):
473
+ return sort
474
+ raise ValueError("axis_logos reads the categories from inline data; give the axis an explicit sort=[...] list")
475
+ key = _key(enc["field"])
476
+ seen = list(dict.fromkeys(r.get(key) for r in rows if r.get(key) is not None))
477
+ if isinstance(sort, list):
478
+ return sort + [c for c in seen if c not in sort]
479
+ if sort in ("ascending", "descending"):
480
+ return sorted(seen, reverse=sort == "descending")
481
+ return seen # sort=None, or a field sort: data order
482
+
483
+
484
+ def _target_channel(chart: Any, channel: str) -> Any:
485
+ """The channel object (alt.X / alt.Y) the chart's spec reads the axis from: the first with a field, as ``_unit``."""
486
+ enc: Any = getattr(chart, "encoding", alt.Undefined)
487
+ ch: Any = alt.Undefined if enc is alt.Undefined else getattr(enc, channel, alt.Undefined)
488
+ # ``field`` or the ``alt.X("team")`` shorthand: reading either does not serialize the channel, which Altair refuses
489
+ # for an untyped channel when the chart's data is not at hand
490
+ if ch is not alt.Undefined and any(
491
+ getattr(ch, a, alt.Undefined) is not alt.Undefined for a in ("field", "shorthand")
492
+ ):
493
+ return ch
494
+ for sub in getattr(chart, "layer", None) or []:
495
+ found = _target_channel(sub, channel)
496
+ if found is not None:
497
+ return found
498
+ return None
499
+
500
+
501
+ def axis_logos(
502
+ chart: Any,
503
+ axis: str,
504
+ *,
505
+ league: str,
506
+ season: Any = None,
507
+ height: float = 0.1,
508
+ variant: str = "default",
509
+ mark_type: str = "logo",
510
+ embed: bool = False,
511
+ id_system: str = "auto",
512
+ ) -> alt.LayerChart:
513
+ """Replace a discrete axis' team labels with the teams' logos (or wordmarks).
514
+
515
+ The images are a layer placed just outside the plot (under the x axis, left of the y axis), ``height`` of the chart
516
+ height tall; the axis' ``labelExpr`` blanks only the labels that became images and its ``labelPadding`` grows past
517
+ them, so labels that are not teams stay as text (with one SdvplotWarning). The categories come from an explicit
518
+ scale domain or sort list, or the chart's inline data.
519
+
520
+ Args:
521
+ chart: An ``altair.Chart`` or ``LayerChart`` with a nominal or ordinal ``axis``.
522
+ axis: "x" or "y".
523
+ league: The SDV league key, e.g. "nfl".
524
+ season: One season for every label.
525
+ height: The image height as a fraction of the chart height, in (0, 1].
526
+ variant: "default", "dark", or a named variant from ``marks()``.
527
+ mark_type: "logo" or "wordmark".
528
+ embed: Inline each image as a data URI instead of linking its URL.
529
+ id_system: The id system of the labels; "auto" tries each in order.
530
+
531
+ Returns:
532
+ altair.LayerChart: A new chart: ``chart`` with the axis labels blanked, plus the image layer.
533
+
534
+ Raises:
535
+ InputError: (a ValueError) If ``height`` is out of range, ``league``, ``id_system``, ``mark_type`` or
536
+ ``variant`` is unknown, or ``season`` is not a year or is outside the seasons sdvplot knows for the league.
537
+ ValueError: If ``axis`` is not "x"/"y", the axis is not discrete or is hidden, the categories cannot be read, or
538
+ the chart cannot take a layer (see ``add_logos``).
539
+ UnsupportedTargetError: (a TypeError) If ``chart`` is not an Altair ``Chart`` or ``LayerChart``.
540
+ OfflineError: If the logo manifest, or with ``embed=True`` a mark's image, is neither cached nor downloadable (a
541
+ DownloadError, also an OSError, when the CDN answers with an error status; an IntegrityError when it sends a
542
+ file that does not match the manifest's sha256).
543
+ UnsafeDownloadError: (an OSError) If a download is refused: larger than the byte cap, past the deadline, or
544
+ redirected away from https.
545
+ UnsafeCachePathError: (a ValueError) If ``embed=True`` and the manifest's sha256 or extension for a mark would
546
+ put the file outside the cache directory.
547
+
548
+ Example:
549
+ ::
550
+
551
+ import altair as alt
552
+ import pandas as pd
553
+ import sdvplot
554
+
555
+ df = pd.DataFrame({"team": ["KC", "BUF", "BAL"], "wins": [12, 10, 9]})
556
+ bars = alt.Chart(df).mark_bar().encode(x=alt.X("team", sort=None), y="wins")
557
+ sdvplot.axis_logos(bars, "x", league="nfl", height=0.1)
558
+
559
+ See Also:
560
+ sdvplotR element_sdv_logo(): https://sdvplotR.sportsdataverse.org/
561
+ """
562
+ letter = axis_letter(axis)
563
+ h = check_height(height)
564
+ _check_chart(chart)
565
+ spec = chart.to_dict()
566
+ unit, enc = _unit(spec, letter)
567
+ if enc.get("type") not in _DISCRETE:
568
+ raise ValueError(f"axis_logos needs a nominal or ordinal {letter} axis (team names on the axis)")
569
+ if enc.get("axis", {}) is None:
570
+ raise ValueError(f"the chart's {letter} axis is hidden (axis=None)")
571
+ sort = _sort(enc, letter)
572
+ cats = _categories(spec, unit, enc)
573
+ index: list[Any] = list(range(len(cats)))
574
+ across: list[Any] = [0.0] * len(cats)
575
+ xs, ys = (index, across) if letter == "x" else (across, index)
576
+ placements = place(xs, ys, [str(c) for c in cats], league=league, season=season, kind=mark_type, variant=variant,
577
+ id_system=id_system) # fmt: skip
578
+ chart_h = _chart_height(spec)
579
+ h_px = h * chart_h
580
+ widest = max((aspect(p) for p in placements), default=1.0)
581
+ pos = [int(p.x if letter == "x" else p.y) for p in placements]
582
+ blank = json.dumps([str(cats[i]) for i in pos])
583
+ # the axis: blank the labels that became images, and move the rest past the images
584
+ old = dict(enc.get("axis", {}))
585
+ room = (h_px if letter == "x" else h_px * widest) + _AXIS_GAP
586
+ expr = f"indexof({blank}, datum.label) >= 0 ? '' : " + (
587
+ f"({old['labelExpr']})" if "labelExpr" in old else "datum.label"
588
+ )
589
+ base = chart.copy(deep=True)
590
+ _target_channel(base, letter).axis = alt.Axis(
591
+ **{**old, "labelExpr": expr, "labelPadding": old.get("labelPadding", _VEGA_LITE_LABEL_PADDING) + room}
592
+ )
593
+ key = _key(enc["field"])
594
+ rows = [{key: cats[i], _URL: src, _TEAM: p.team_id}
595
+ for i, p, src in zip(pos, placements, image_sources(placements, embed=embed), strict=True)] # fmt: skip
596
+ channel = {"field": enc["field"], "type": enc["type"], **({"sort": sort} if "sort" in enc else {})}
597
+ layer = alt.Chart(alt.Data(values=rows), name=f"sdvplot_axis_{letter}")
598
+ url = alt.Url(_URL, type="nominal")
599
+ if letter == "x": # under the plot: the images hang from just below the x axis
600
+ layer = layer.mark_image(width=h_px * widest, height=h_px, aspect=True, baseline="top").encode(
601
+ x=alt.X(**channel), y=alt.value(chart_h + _AXIS_GAP), url=url
602
+ )
603
+ else: # left of the plot: the images end just left of the y axis
604
+ layer = layer.mark_image(width=h_px * widest, height=h_px, aspect=True, align="right").encode(
605
+ y=alt.Y(**channel), x=alt.value(-_AXIS_GAP), url=url
606
+ )
607
+ return base + layer
608
+
609
+
610
+ def _named(chart: Any, prefix: str) -> list[Any]:
611
+ """Every layer of ``chart`` (itself included) whose name starts with ``prefix``, in layer order."""
612
+ found = [chart] if isinstance(getattr(chart, "name", None), str) and chart.name.startswith(prefix) else []
613
+ for sub in getattr(chart, "layer", []) or []:
614
+ found += _named(sub, prefix)
615
+ return found
616
+
617
+
618
+ def _drawn_marks(chart: Any) -> list[tuple[Any, ...]]:
619
+ """Test hook: (team_id, x, y, height, url) per image of the sdvplot layers; height = image px / chart px."""
620
+ ref = _chart_height(chart.to_dict())
621
+ out = []
622
+ for layer in _named(chart, "sdvplot_"):
623
+ if layer.name.startswith("sdvplot_axis_"):
624
+ continue
625
+ xk, yk = _key(layer.encoding.x.to_dict()["field"]), _key(layer.encoding.y.to_dict()["field"])
626
+ out += [(r[_TEAM], r[xk], r[yk], layer.mark.height / ref, r[_URL]) for r in layer.data.values]
627
+ return out
628
+
629
+
630
+ def _drawn_axis_marks(chart: Any, axis: str) -> list[tuple[str, float, float]]:
631
+ """Test hook: (team_id, category position, height) for each image on ``axis``, in tick order; height = image px /
632
+ chart px."""
633
+ letter = axis_letter(axis)
634
+ spec = chart.to_dict()
635
+ unit, enc = _unit(spec, letter)
636
+ cats = _categories(spec, unit, enc)
637
+ ref = _chart_height(spec)
638
+ marks = []
639
+ for layer in _named(chart, f"sdvplot_axis_{letter}"):
640
+ key = _key(getattr(layer.encoding, letter).to_dict()["field"])
641
+ marks += [(r[_TEAM], float(cats.index(r[key])), layer.mark.height / ref) for r in layer.data.values]
642
+ return sorted(marks, key=lambda m: m[1])
643
+
644
+
645
+ def _visible_axis_labels(chart: Any, axis: str) -> list[str]:
646
+ """Test hook: the labels on ``axis`` the axis' labelExpr still shows as text."""
647
+ letter = axis_letter(axis)
648
+ spec = chart.to_dict()
649
+ unit, enc = _unit(spec, letter)
650
+ found = _BLANKED.match(enc.get("axis", {}).get("labelExpr", ""))
651
+ blanked = set(json.loads(found.group(1))) if found else set()
652
+ return [str(c) for c in _categories(spec, unit, enc) if str(c) not in blanked]