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/_resolve.py ADDED
@@ -0,0 +1,363 @@
1
+ """Turn whatever team identifiers a user has into canonical (league, team_id) keys, never guessing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import numbers
7
+ from collections.abc import Callable, Sequence
8
+ from typing import TYPE_CHECKING, Any, overload
9
+
10
+ from sdvplot import _index
11
+ from sdvplot._errors import InputError, UnresolvedTeamError, warn
12
+ from sdvplot._normalize import _is_na, check_season, norm_season, norm_value
13
+ from sdvplot._types import IdSystem
14
+
15
+ if TYPE_CHECKING:
16
+ import numpy as np
17
+ import pandas as pd
18
+ import polars as pl
19
+ else:
20
+ from sdvplot._lazy import pl
21
+
22
+ # The order "auto" tries id systems in; the first system with a candidate decides
23
+ PRIORITY: tuple[str, ...] = (
24
+ "team_id",
25
+ "espn",
26
+ "espn_abbr",
27
+ "nhl",
28
+ "nflverse",
29
+ "mlbstats",
30
+ "nba_api",
31
+ "hockeytech",
32
+ "ncaa",
33
+ "pff",
34
+ "cricinfo",
35
+ "cfbd",
36
+ "bref",
37
+ "sportsipy",
38
+ "fangraphs",
39
+ "sdvplotr", # sdvplotR's clean_team_abbrs keys: after every id system, so it only fills gaps (R43)
40
+ "name",
41
+ )
42
+ # Systems "auto" never tries, only an explicit id_system: NHL stats API ids 1-28 are other teams' ESPN ids (R49)
43
+ EXPLICIT_ONLY: tuple[str, ...] = ("nhl_id",)
44
+ Candidates = list[tuple[str, int | None, int | None]]
45
+
46
+
47
+ @functools.cache
48
+ def _lookup(league: str) -> dict[str, dict[str, Candidates]]:
49
+ """id_system -> normalized value -> [(team_id, valid_from, valid_to)] for one league."""
50
+ out: dict[str, dict[str, Candidates]] = {}
51
+ for r in _index.alias_table().filter(pl.col("league") == league).iter_rows(named=True):
52
+ key = norm_value(r["value"])
53
+ if key is not None:
54
+ out.setdefault(r["id_system"], {}).setdefault(key, []).append(
55
+ (r["team_id"], r["valid_from"], r["valid_to"])
56
+ )
57
+ return out
58
+
59
+
60
+ _index.on_reload(_lookup.cache_clear)
61
+
62
+
63
+ @functools.cache
64
+ def _latest(league: str) -> int | None:
65
+ """The latest season any of the league's id-system aliases names (a range's end, else its start); None if none
66
+ is dated. A value given without a season is read in this season first: the code's current holder."""
67
+ a = _index.alias_table().filter((pl.col("league") == league) & (pl.col("id_system") != "mark"))
68
+ latest = a.select(pl.max_horizontal(pl.col("valid_from").max(), pl.col("valid_to").max())).item()
69
+ return None if latest is None else int(latest)
70
+
71
+
72
+ _index.on_reload(_latest.cache_clear)
73
+
74
+
75
+ def _covers(lo: int | None, hi: int | None, season: int) -> bool:
76
+ return (lo is None or season >= lo) and (hi is None or season <= hi)
77
+
78
+
79
+ def _match(
80
+ key: str,
81
+ season: int | None,
82
+ systems: Sequence[str],
83
+ table: dict[str, dict[str, Candidates]],
84
+ latest: int | None = None,
85
+ ) -> Any:
86
+ """The team_id for one key, None if unknown, or a sorted tuple of the team_ids when the deciding system names
87
+ several teams (ambiguous).
88
+
89
+ With a season, a first pass only considers aliases whose range covers it, so a reused code such as "LA" goes
90
+ to the team that used it that season. Then, or first without a season, a pass reads the ``latest`` season, so a
91
+ code two franchises used (KCA: the 1955-67 Kansas City Athletics, the Royals since) means its current holder.
92
+ A last pass ignores ranges, so a unique code still resolves when no range covers the season (a modern
93
+ abbreviation used on old data)."""
94
+ for when in [*(s for s in dict.fromkeys((season, latest)) if s is not None), None]:
95
+ for system in systems:
96
+ cands = table.get(system, {}).get(key)
97
+ if not cands:
98
+ continue
99
+ if when is not None:
100
+ cands = [c for c in cands if _covers(c[1], c[2], when)]
101
+ if not cands:
102
+ continue
103
+ ids = {c[0] for c in cands}
104
+ return next(iter(ids)) if len(ids) == 1 else tuple(sorted(ids))
105
+ return None
106
+
107
+
108
+ def _scalar(values: Any) -> tuple[bool, Any]:
109
+ """(True, the value) for one value, 0-d numpy arrays included (np.array("KC") is "KC"); else (False, values)."""
110
+ if getattr(values, "ndim", None) == 0 and hasattr(values, "item"):
111
+ values = values.item()
112
+ # numbers.Number covers numpy scalars such as np.int64
113
+ one = values is None or isinstance(values, (str, bytes, numbers.Number)) or type(values).__name__ == "NAType"
114
+ return one, values
115
+
116
+
117
+ def one_team(team: Any, fn: str) -> Any:
118
+ """team as a scalar, or a TypeError naming fn: the mark functions answer for one team at a time."""
119
+ one, value = _scalar(team)
120
+ if not one:
121
+ raise TypeError(f"{fn}() takes one team, got {type(team).__name__}; use resolve() for several")
122
+ return value
123
+
124
+
125
+ def _unpack(values: Any) -> tuple[list[Any], Callable[[list[Any]], Any]]:
126
+ """values as a list, plus a function that wraps a same-length result back into the caller's container."""
127
+ one, values = _scalar(values)
128
+ if one:
129
+ return [values], lambda out: out[0]
130
+ if isinstance(values, (list, tuple)):
131
+ return list(values), list
132
+ if hasattr(values, "tolist") and not hasattr(values, "to_list"): # numpy arrays
133
+ kind = getattr(values.dtype, "kind", "")
134
+ if kind in ("m", "M"):
135
+ import numpy as np # importable: values is a numpy array
136
+
137
+ if np.datetime_data(values.dtype)[0] in ("ns", "ps", "fs", "as"):
138
+ # tolist() turns sub-microsecond datetime64/timedelta64 (pandas' default) into integers
139
+ values = values.astype(f"{kind}8[us]")
140
+ return list(values.tolist()), list
141
+ return _unpack_series(values)
142
+
143
+
144
+ def _unpack_series(values: Any) -> tuple[list[Any], Callable[[list[Any]], Any]]:
145
+ """A pandas/polars (or any narwhals-supported) Series in; a same-library String Series of results out."""
146
+ import narwhals as nw
147
+
148
+ try:
149
+ s = nw.from_native(values, series_only=True)
150
+ except TypeError as e:
151
+ raise TypeError(
152
+ f"resolve() takes a scalar, list, tuple, numpy array or a pandas/polars Series, got {type(values).__name__}"
153
+ ) from e
154
+ backend = nw.get_native_namespace(s)
155
+ # Capture the original index for pandas-like Series
156
+ original_input = values
157
+ idx = nw.maybe_get_index(s)
158
+
159
+ def wrap_result(out: list[Any]) -> Any:
160
+ result = nw.new_series(s.name, out, nw.String(), backend=backend).to_native()
161
+ # Restore the index for pandas-like objects
162
+ if idx is not None and hasattr(result, "index"):
163
+ result.index = original_input.index
164
+ return result
165
+
166
+ return s.to_list(), wrap_result
167
+
168
+
169
+ def _seasons(season: Any, n: int, league: str | None = None) -> list[int | None]:
170
+ one, season = _scalar(season)
171
+ if one or _is_na(season):
172
+ return [norm_season(season, league)] * n # the league's own bounds: one error naming them
173
+ try:
174
+ items, _ = _unpack(season)
175
+ except TypeError: # _unpack's message names values; this is the season argument
176
+ raise InputError(f"season must be a year, or one per team, got {type(season).__name__}") from None
177
+ if len(items) != n:
178
+ raise InputError(f"season has {len(items)} values but there are {n} teams")
179
+ return [norm_season(s, league) for s in items]
180
+
181
+
182
+ def _report(unresolved: dict[str, str], league: str, strict: bool) -> None:
183
+ shown = ", ".join(f"{v!r} ({why})" for v, why in unresolved.items())
184
+ msg = f"{len(unresolved)} value(s) did not resolve to a {league} team: {shown}"
185
+ if any(why.startswith("ambiguous") for why in unresolved.values()):
186
+ # the candidates are listed: what picks one is a season (a reused code) or the id system the values come from
187
+ msg += "; pass season= for a code reused across eras, or id_system= for the id system of the values"
188
+ if strict:
189
+ raise UnresolvedTeamError(msg)
190
+ warn(msg + ". Use sdvplot.suggest() for candidates, or strict=True to raise.")
191
+
192
+
193
+ def _ambiguous(team_ids: tuple[str, ...], league: str) -> str:
194
+ """'ambiguous: 2390 Miami Hurricanes or 193 Miami (OH) RedHawks': the teams one value could mean."""
195
+ names = dict(_index.teams(league).select("team_id", "name").iter_rows())
196
+ return "ambiguous: " + " or ".join(f"{tid} {names.get(tid, '')}".strip() for tid in team_ids)
197
+
198
+
199
+ # The result comes back in the container the values came in (_unpack). The last overload takes what the others do not
200
+ # name: numpy scalars, pandas NA, other narwhals backends. pandas ships no types, so without pandas-stubs (as here)
201
+ # pd.Series is Any and the last overload is never reached: hence its ignore.
202
+ @overload
203
+ def resolve(
204
+ values: str | bytes | int | float | None,
205
+ league: str,
206
+ *,
207
+ season: Any = None,
208
+ id_system: IdSystem = "auto",
209
+ strict: bool = False,
210
+ ) -> str | None: ...
211
+ @overload
212
+ def resolve(
213
+ values: pl.Series, league: str, *, season: Any = None, id_system: IdSystem = "auto", strict: bool = False
214
+ ) -> pl.Series: ...
215
+ @overload
216
+ def resolve(
217
+ values: list[Any] | tuple[Any, ...] | np.ndarray[Any, Any],
218
+ league: str,
219
+ *,
220
+ season: Any = None,
221
+ id_system: IdSystem = "auto",
222
+ strict: bool = False,
223
+ ) -> list[str | None]: ...
224
+ @overload
225
+ def resolve(
226
+ values: pd.Series, league: str, *, season: Any = None, id_system: IdSystem = "auto", strict: bool = False
227
+ ) -> pd.Series: ...
228
+ @overload
229
+ def resolve( # type: ignore[overload-cannot-match]
230
+ values: Any, league: str, *, season: Any = None, id_system: IdSystem = "auto", strict: bool = False
231
+ ) -> Any: ...
232
+ def resolve(values: Any, league: str, *, season: Any = None, id_system: IdSystem = "auto", strict: bool = False) -> Any:
233
+ """Canonical team_id(s) for team values in one league.
234
+
235
+ Accepts ids and names from any supported source (ESPN, nflverse, MLB Stats, nba_api, HockeyTech, CFBD, sdvplotR) and
236
+ returns the bundled index's canonical team_id, in the same container the values came in. Values that do not resolve
237
+ come back as None with one SdvplotWarning, or raise with ``strict=True``.
238
+
239
+ Args:
240
+ values: A scalar, list/tuple, numpy array, or pandas/polars Series of team identifiers in any supported id
241
+ system. NHL stats API ids overlap ESPN's, so "auto" never reads a number as one: pass
242
+ ``id_system="nhl_id"`` for them (NHL tri-codes such as "NJD" resolve under "auto").
243
+ league: The SDV league key, e.g. "nfl", "cfb", "ohl". Required: the same abbreviation means different teams in
244
+ different leagues.
245
+ season: One season for all values, or one per value. Picks the right team for a reused code; without one,
246
+ a reused code means its current holder (KCA: the Royals, not the 1955-67 Kansas City Athletics).
247
+ id_system: "auto" (try the priority order) or one system name.
248
+ strict: Raise UnresolvedTeamError instead of warning when a value does not resolve.
249
+
250
+ Returns:
251
+ str | list | Series | None: The same shape as ``values``: a team_id string (or None), a list, or a Series of the
252
+ caller's library.
253
+
254
+ Raises:
255
+ TypeError: If ``values`` is not a scalar, list, tuple, numpy array, or pandas/polars Series.
256
+ InputError: (a ValueError) If ``league`` or ``id_system`` is unknown, or ``season`` is neither a year nor a
257
+ list of them (a ``pd.Timestamp``, ``"2020-21"``), is outside the seasons sdvplot knows for the league
258
+ (from its first dated season in the bundled index, 1920 for the NFL, to next year), or is a list whose
259
+ length does not match the teams.
260
+ UnresolvedTeamError: If ``strict=True`` and a value does not resolve.
261
+
262
+ Example:
263
+ ::
264
+
265
+ import sdvplot
266
+
267
+ sdvplot.resolve("LV", "nfl") # '13'
268
+ sdvplot.resolve(["KC", "SF"], "nfl") # ['12', '25']
269
+
270
+ See Also:
271
+ sdvplotR: https://sdvplotR.sportsdataverse.org/ ;
272
+ sdv-py: https://py.sportsdataverse.org/
273
+ """
274
+ _index.check_league(league)
275
+ _systems(id_system)
276
+ items, wrap = _unpack(values)
277
+ out, unresolved = _resolve_ids(items, league, _seasons(season, len(items), league), id_system)
278
+ if unresolved:
279
+ _report(unresolved, league, strict)
280
+ return wrap(out)
281
+
282
+
283
+ def _systems(id_system: str) -> tuple[str, ...]:
284
+ if id_system != "auto" and id_system not in PRIORITY + EXPLICIT_ONLY:
285
+ raise InputError(f"unknown id_system {id_system!r}; use 'auto' or one of {list(PRIORITY + EXPLICIT_ONLY)}")
286
+ return PRIORITY if id_system == "auto" else (id_system,)
287
+
288
+
289
+ def _resolve_ids(
290
+ items: list[Any], league: str, seasons: list[int | None], id_system: str
291
+ ) -> tuple[list[str | None], dict[str, str]]:
292
+ """resolve() without the report: the ids (None where a value does not resolve), and each unresolved value with
293
+ why ("unknown", or "ambiguous: " and the teams it could mean). The caller decides whether to warn."""
294
+ _index.check_league(league)
295
+ systems = _systems(id_system)
296
+ for year in {s for s in seasons if s is not None}: # every season path resolves here: the league's own range
297
+ check_season(year, league)
298
+ table, latest = _lookup(league), _latest(league)
299
+ out: list[str | None] = []
300
+ unresolved: dict[str, str] = {}
301
+ memo: dict[tuple[str, int | None], Any] = {}
302
+ for value, s in zip(items, seasons, strict=True):
303
+ key = norm_value(value)
304
+ if key is None:
305
+ out.append(None)
306
+ continue
307
+ if (key, s) not in memo:
308
+ memo[(key, s)] = _match(key, s, systems, table, latest)
309
+ hit = memo[(key, s)]
310
+ if hit is None or isinstance(hit, tuple):
311
+ unresolved[str(value)] = "unknown" if hit is None else _ambiguous(hit, league)
312
+ out.append(None)
313
+ else:
314
+ out.append(hit)
315
+ return out, unresolved
316
+
317
+
318
+ def suggest(value: Any, league: str, *, n: int = 5) -> list[tuple[str, str]]:
319
+ """Up to n (team_id, name) candidates for a value that did not resolve, best first.
320
+
321
+ It never picks one for you: similar names can be different teams ("Bethany (KS)" and "Bethany (WV)").
322
+
323
+ Args:
324
+ value: The team value that failed to resolve.
325
+ league: The SDV league key, e.g. "nfl".
326
+ n: The most candidates to return.
327
+
328
+ Returns:
329
+ list[tuple[str, str]]: ``(team_id, name)`` pairs, best match first; empty when nothing is close.
330
+
331
+ Raises:
332
+ InputError: (a ValueError) If ``league`` is unknown, or ``n`` is not an int of at least 1.
333
+
334
+ Example:
335
+ ::
336
+
337
+ import sdvplot
338
+
339
+ sdvplot.suggest("Kansas Cty Chiefs", "nfl", n=2) # [('12', 'Kansas City Chiefs')]
340
+
341
+ See Also:
342
+ sdvplotR: https://sdvplotR.sportsdataverse.org/ ;
343
+ sdv-py: https://py.sportsdataverse.org/
344
+ """
345
+ import difflib
346
+
347
+ _index.check_league(league)
348
+ if isinstance(n, bool) or not isinstance(n, numbers.Integral) or n < 1:
349
+ raise InputError(f"n is the most candidates to return, an int of at least 1, got {n!r}")
350
+ key = norm_value(value)
351
+ if key is None:
352
+ return []
353
+ merged: dict[str, list[str]] = {}
354
+ for system in _lookup(league).values():
355
+ for k, cands in system.items():
356
+ merged.setdefault(k, []).extend(c[0] for c in cands)
357
+ names = dict(_index.teams(league).select("team_id", "name").iter_rows())
358
+ out: list[tuple[str, str]] = []
359
+ for k in difflib.get_close_matches(key, list(merged), n=n * 3, cutoff=0.6):
360
+ for tid in merged[k]:
361
+ if tid not in {t for t, _ in out}:
362
+ out.append((tid, names.get(tid, tid)))
363
+ return out[:n]
sdvplot/_surface.py ADDED
@@ -0,0 +1,220 @@
1
+ """sportypy playing surfaces in team colors: the port of sdvplotR's sdv_surface()."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import importlib
7
+ from collections.abc import Iterator
8
+ from typing import Any
9
+
10
+ from sdvplot._colors import team_colors
11
+ from sdvplot._contrast import contrast, on_color
12
+ from sdvplot._errors import InputError, OptionalDependencyError
13
+
14
+ # SDV league -> (sportypy.surfaces module, class). Always the sport module: the top-level sportypy.surfaces.NCAACourt
15
+ # is the tennis court and NCAAField the football field, shadowing the basketball court and the baseball field.
16
+ SURFACES: dict[str, tuple[str, str]] = {
17
+ "nfl": ("football", "NFLField"),
18
+ "aaf": ("football", "NFLField"),
19
+ "ufl": ("football", "NFLField"),
20
+ "usfl": ("football", "NFLField"),
21
+ "xfl": ("football", "NFLField"),
22
+ "cfb": ("football", "NCAAField"),
23
+ "nba": ("basketball", "NBACourt"),
24
+ "nbagl": ("basketball", "NBAGLeagueCourt"),
25
+ "wnba": ("basketball", "WNBACourt"),
26
+ "mbb": ("basketball", "NCAACourt"),
27
+ "wbb": ("basketball", "NCAACourt"),
28
+ "mlb": ("baseball", "MLBField"),
29
+ "milb": ("baseball", "MiLBField"),
30
+ "ncaa_baseball": ("baseball", "NCAAField"),
31
+ "nhl": ("hockey", "NHLRink"),
32
+ "whl": ("hockey", "NHLRink"),
33
+ "pwhl": ("hockey", "NHLRink"),
34
+ "ahl": ("hockey", "AHLRink"),
35
+ "echl": ("hockey", "ECHLRink"),
36
+ "ohl": ("hockey", "OHLRink"),
37
+ "qmjhl": ("hockey", "QMJHLRink"),
38
+ "ushl": ("hockey", "USHLRink"),
39
+ "phf": ("hockey", "PHFRink"),
40
+ "ncaa_mhockey": ("hockey", "NCAARink"),
41
+ "ncaa_whockey": ("hockey", "NCAARink"),
42
+ "soccer": ("soccer", "FIFAPitch"),
43
+ }
44
+ SURFACE_BASE = {"basketball": "#d2ab6f", "football": "#196f0c", "hockey": "#ffffff"} # sportyR's default colors
45
+ _DRAW_KWARGS = ("display_range", "xlim", "ylim", "rotation")
46
+ _NUMBER_FONT = "Clarendon-Regular" # sportypy's football yard-line number font (data/surface_dimensions.json)
47
+
48
+
49
+ def _has_font(family: str) -> bool:
50
+ """Whether matplotlib can find ``family`` itself; asking with no fallback logs nothing."""
51
+ from matplotlib import font_manager
52
+
53
+ try:
54
+ font_manager.findfont(font_manager.FontProperties(family=family), fallback_to_default=False)
55
+ except ValueError:
56
+ return False
57
+ return True
58
+
59
+
60
+ @contextlib.contextmanager
61
+ def _polygon_limits(ax: Any) -> Iterator[None]:
62
+ """Within the block, ``ax.add_patch`` takes a polygon's data limits from its vertices in one call.
63
+
64
+ matplotlib walks every segment of an added patch as a Bezier curve to find its extrema, and sportypy draws its
65
+ circles and arcs as 10,000-point polygons: a rink or court walked ~1.3 M segments, 16-19 s. A straight segment's
66
+ extrema are its two ends, so a polygon's vertices are exactly the points matplotlib's walk measures (the CLOSEPOLY
67
+ slot is not a point; one vertex alone is no segment). Anything else (a curve, a NaN, a transform not in data on
68
+ both axes, a non-rectilinear Axes) goes through matplotlib's own walk.
69
+ """
70
+ import numpy as np
71
+ from matplotlib.patches import Polygon
72
+ from matplotlib.path import Path
73
+
74
+ straight = (Path.LINETO, Path.CLOSEPOLY)
75
+
76
+ walk = ax._update_patch_limits # the Axes' own updater, or one set on it before this block
77
+ own = vars(ax).get("_update_patch_limits")
78
+
79
+ def update(patch: Any) -> None:
80
+ path, transform = patch.get_path(), patch.get_transform()
81
+ codes, vertices = path.codes, path.vertices
82
+ if not (
83
+ isinstance(patch, Polygon)
84
+ and len(vertices) > 1
85
+ and (codes is None or (codes[0] == Path.MOVETO and np.isin(codes[1:], straight).all()))
86
+ and np.isfinite(vertices).all()
87
+ and ax.name == "rectilinear"
88
+ and transform.contains_branch(ax.transData)
89
+ ):
90
+ walk(patch)
91
+ return
92
+ points = vertices if codes is None else vertices[codes != Path.CLOSEPOLY]
93
+ ax.update_datalim((transform - ax.transData).transform(points))
94
+
95
+ ax._update_patch_limits = update
96
+ try:
97
+ yield
98
+ finally:
99
+ if own is None:
100
+ del ax._update_patch_limits
101
+ else:
102
+ ax._update_patch_limits = own
103
+
104
+
105
+ def color_updates(sport: str, primary: str, secondary: str | None) -> dict[str, str]:
106
+ """sportypy ``color_updates`` for a team (sdvplotR's ``surface_color_updates()``): basketball paints the lane and
107
+ apron with a readable ink on top, football the end zones, hockey the center line, faceoff circle and spot and the
108
+ boards (the secondary color when the primary does not read on ice); baseball and soccer nothing."""
109
+ if sport == "basketball":
110
+ ink = on_color(primary)
111
+ return {
112
+ "painted_area": primary,
113
+ "court_apron": primary,
114
+ "restricted_arc": ink,
115
+ "free_throw_circle_dash": ink,
116
+ "lane_lower_defensive_box": ink,
117
+ "baseline_lower_defensive_box": ink,
118
+ }
119
+ if sport == "football":
120
+ return {"offensive_endzone": primary, "defensive_endzone": primary}
121
+ if sport == "hockey":
122
+ ice = SURFACE_BASE["hockey"]
123
+ readable = secondary is not None and contrast(primary, ice) < 3 and contrast(secondary, ice) >= 3
124
+ accent = secondary if readable and secondary is not None else primary
125
+ return {
126
+ "center_line": accent,
127
+ "center_faceoff_circle": accent,
128
+ "center_faceoff_spot": accent,
129
+ "boards": primary,
130
+ }
131
+ return {}
132
+
133
+
134
+ def surface(
135
+ league: str,
136
+ team: Any = None,
137
+ *,
138
+ season: Any = None,
139
+ ax: Any = None,
140
+ center_logo: bool | float = False,
141
+ **sportypy_kwargs: Any,
142
+ ) -> Any:
143
+ """Draw the league's playing surface with sportypy, in a team's colors.
144
+
145
+ Args:
146
+ league: The SDV league key, e.g. "nfl", "nba", "nhl", "cfb", "soccer".
147
+ team: A team to color the surface by (end zones, lane and apron, center line and boards); None for the plain
148
+ surface.
149
+ season: The season, for teams whose colors changed.
150
+ ax: The matplotlib Axes to draw on; None makes a new figure.
151
+ center_logo: True to draw the team's logo at center ice / court / field (0.25 of the Axes height), or a
152
+ height fraction.
153
+ **sportypy_kwargs: Passed to sportypy: ``display_range``, ``xlim``, ``ylim`` and ``rotation`` go to
154
+ ``draw()``; the rest (``field_updates``, ``color_updates``, ``units``, ...) to the surface class.
155
+
156
+ Returns:
157
+ matplotlib.axes.Axes: The Axes sportypy drew on.
158
+
159
+ Raises:
160
+ InputError: (a ValueError) If sportypy has no surface for ``league``; and, when ``team`` is given, if
161
+ ``season`` is not a year or is outside the seasons sdvplot knows for the league, or ``center_logo`` is a
162
+ height outside (0, 1]. Without ``team``, ``season`` and ``center_logo`` are not used.
163
+ OptionalDependencyError: If the surfaces extra (sportypy) is not installed, or ``team`` and ``center_logo`` are
164
+ set and the team's logo is an SVG without the ``svg`` extra.
165
+ OfflineError: If ``team`` and ``center_logo`` are set and the logo manifest or the team's logo is neither
166
+ cached nor downloadable (a DownloadError, also an OSError, when the CDN answers with an error status; an
167
+ IntegrityError when it sends a file that does not match the manifest's sha256, or one PIL cannot decode).
168
+ UnsafeDownloadError: (an OSError) If ``team`` and ``center_logo`` are set and a download is refused: larger
169
+ than the byte cap, past the deadline, or redirected away from https.
170
+ UnsafeCachePathError: (a ValueError) If ``team`` and ``center_logo`` are set and the manifest's sha256 or
171
+ extension for the logo would put the file outside the cache directory.
172
+
173
+ Example:
174
+ ::
175
+
176
+ import sdvplot
177
+
178
+ ax = sdvplot.surface("nfl", "KC", center_logo=True)
179
+
180
+ See Also:
181
+ sdvplotR sdv_surface(): https://sdvplotR.sportsdataverse.org/ ;
182
+ sportypy: https://sportypy.sportsdataverse.org/
183
+ """
184
+ if league not in SURFACES:
185
+ raise InputError(f"no sportypy surface for league {league!r}; supported: {sorted(SURFACES)}")
186
+ sport, cls_name = SURFACES[league]
187
+ try:
188
+ module = importlib.import_module(f"sportypy.surfaces.{sport}")
189
+ except ModuleNotFoundError as e:
190
+ raise OptionalDependencyError(
191
+ "sdvplot.surface() needs the surfaces extra: pip install sdvplot[surfaces]"
192
+ ) from e
193
+ draw_kwargs = {k: sportypy_kwargs.pop(k) for k in _DRAW_KWARGS if k in sportypy_kwargs}
194
+ if sport == "football" and not _has_font(_NUMBER_FONT):
195
+ # sportypy numbers football fields in Clarendon-Regular, which it does not ship: matplotlib then logs "findfont:
196
+ # Font family 'Clarendon-Regular' not found" for every number it measures and draws its default font anyway.
197
+ # Naming that default draws the same numbers (bar sportypy's Clarendon-only nudge of one "1") and logs nothing.
198
+ sportypy_kwargs["field_updates"] = {"number_font": "DejaVu Sans", **sportypy_kwargs.get("field_updates", {})}
199
+ if team is not None:
200
+ primary, secondary = (team_colors(league, [team], which=w, season=season)[0] for w in ("primary", "secondary"))
201
+ if primary is not None:
202
+ sportypy_kwargs["color_updates"] = {
203
+ **color_updates(sport, primary, secondary),
204
+ **sportypy_kwargs.get("color_updates", {}),
205
+ }
206
+ drawing = getattr(module, cls_name)(**sportypy_kwargs)
207
+ if ax is None: # as sportypy's draw() does, so the limits shortcut has an Axes to go on
208
+ import matplotlib.pyplot as plt
209
+
210
+ fig, ax = plt.subplots()
211
+ fig.patch.set_facecolor(drawing.feature_colors["plot_background"])
212
+ with _polygon_limits(ax):
213
+ drawn = drawing.draw(ax=ax, **draw_kwargs)
214
+ if center_logo and team is not None:
215
+ from sdvplot.matplotlib import add_logos
216
+
217
+ top = max((a.get_zorder() for a in drawn.get_children()), default=0)
218
+ add_logos(drawn, [0.0], [0.0], [team], league=league, season=season, zorder=top + 1,
219
+ height=0.25 if center_logo is True else float(center_logo)) # fmt: skip
220
+ return drawn
sdvplot/_tables.py ADDED
@@ -0,0 +1,87 @@
1
+ """What the table adapters share (sdvplot.great_tables, sdvplot.reactable): pixel heights and a mark's ``<img>`` markup.
2
+
3
+ No table library is imported here, so either adapter works without the other installed.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import html
9
+ import math
10
+ import numbers
11
+ from typing import Any
12
+
13
+ import polars as pl
14
+
15
+ from sdvplot import _index
16
+ from sdvplot._errors import InputError
17
+ from sdvplot._normalize import norm_season
18
+ from sdvplot._placement import place
19
+
20
+
21
+ def _position(row: Any) -> Any:
22
+ return int(row) if isinstance(row, numbers.Integral) and not isinstance(row, bool) else row
23
+
24
+
25
+ def row_positions(rows: Any) -> Any:
26
+ """A great_tables row selection with numpy integers made plain ``int``s.
27
+
28
+ great_tables' row resolver silently skips any position that is not an ``int``, so ``rows=[np.int64(1)]`` (or a
29
+ numpy array of positions) would select nothing. Expressions, callables and ``None`` pass through unchanged.
30
+ """
31
+ if isinstance(rows, numbers.Integral) and not isinstance(rows, bool):
32
+ return [int(rows)]
33
+ if getattr(rows, "ndim", None) == 1 and hasattr(rows, "tolist"): # a numpy array of positions
34
+ rows = rows.tolist()
35
+ if isinstance(rows, (list, tuple)):
36
+ return [_position(r) for r in rows]
37
+ return rows
38
+
39
+
40
+ def check_px(height: Any) -> float:
41
+ """``height`` as a float, or ValueError unless it is a finite number of pixels, at least 1."""
42
+ if isinstance(height, bool) or not isinstance(height, numbers.Real) or not math.isfinite(height) or height <= 0:
43
+ raise InputError(f"height is the image height in pixels, a number > 0, got {height!r}")
44
+ if height < 1: # a plot's height is a fraction of the plot; a table's is pixels, so 0.1 would draw a 0.1 px image
45
+ raise InputError(
46
+ f"height is the image height in pixels for a table (such as 30), got {height!r}; a fraction of the plot "
47
+ "height is the unit for plots, not tables"
48
+ )
49
+ return float(height)
50
+
51
+
52
+ def img_tag(src: str, height: float, alt: str, *, team: str | None = None, margin: bool = False) -> str:
53
+ """An ``<img>`` ``height`` pixels tall; ``team`` adds the ``data-sdvplot-team`` attribute the test hooks read."""
54
+ style = f"height:{height:g}px;vertical-align:middle" + (";margin-right:0.35em" if margin else "")
55
+ data = f' data-sdvplot-team="{html.escape(team)}"' if team is not None else ""
56
+ return f'<img src="{html.escape(src)}" style="{style}" alt="{html.escape(alt)}"{data}>'
57
+
58
+
59
+ def mark_html(
60
+ values: list[Any],
61
+ *,
62
+ league: str,
63
+ kind: str,
64
+ height: float,
65
+ season: Any = None,
66
+ variant: str = "default",
67
+ id_system: str = "auto",
68
+ strict: bool = False,
69
+ include_name: bool = False,
70
+ ) -> list[str | None]:
71
+ """The ``<img>`` of each value's mark, or None where there is none (one SdvplotWarning for those, from place()).
72
+
73
+ ``season`` is one season for every value (sdvplotR's rule for tables); a list of seasons is a ValueError. The alt
74
+ text is the team's name from the bundled index (the player id for headshots).
75
+ """
76
+ s = norm_season(season)
77
+ n = len(values)
78
+ placed = place(list(range(n)), [0] * n, values, league=league, season=s, kind=kind, variant=variant,
79
+ id_system=id_system, strict=strict) # fmt: skip
80
+ names: dict[str, str] = {}
81
+ if kind != "headshot":
82
+ rows = _index.team_table().filter(pl.col("league") == league).select("team_id", "name").iter_rows()
83
+ names = {tid: name for tid, name in rows if name}
84
+ out: list[str | None] = [None] * n
85
+ for p in placed:
86
+ out[p.x] = img_tag(p.url, height, names.get(p.team_id, p.team_id), team=p.team_id, margin=include_name)
87
+ return out