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