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/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]
|