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/_colors.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Team colors as plain {team: "#hex"} dicts, which seaborn, Plotly, Altair, Bokeh and PyPalettes all accept."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from typing import TYPE_CHECKING, Any, overload
|
|
7
|
+
|
|
8
|
+
from sdvplot import _index
|
|
9
|
+
from sdvplot._errors import InputError, warn
|
|
10
|
+
from sdvplot._resolve import _seasons, _unpack, resolve
|
|
11
|
+
from sdvplot._types import IdSystem, Which
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pandas as pd
|
|
16
|
+
import polars as pl
|
|
17
|
+
else:
|
|
18
|
+
from sdvplot._lazy import pl
|
|
19
|
+
|
|
20
|
+
_COLUMNS = {"primary": "color_primary", "secondary": "color_secondary"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _column(which: str) -> str:
|
|
24
|
+
if which not in _COLUMNS:
|
|
25
|
+
raise InputError(f"which must be one of {sorted(_COLUMNS)}, got {which!r}")
|
|
26
|
+
return _COLUMNS[which]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _colors(league: str, column: str) -> dict[str, str]:
|
|
30
|
+
t = _index.team_table().filter(pl.col("league") == league)
|
|
31
|
+
return {tid: c for tid, c in t.select("team_id", column).iter_rows() if c}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def palette(
|
|
35
|
+
league: str,
|
|
36
|
+
teams: Any = None,
|
|
37
|
+
*,
|
|
38
|
+
which: Which = "primary",
|
|
39
|
+
season: Any = None,
|
|
40
|
+
id_system: IdSystem = "auto",
|
|
41
|
+
strict: bool = False,
|
|
42
|
+
) -> dict[Any, str]:
|
|
43
|
+
"""A ``{team: "#hex"}`` dict for a league, ready for seaborn, Plotly, Altair, Bokeh or PyPalettes.
|
|
44
|
+
|
|
45
|
+
Without ``teams`` the keys are canonical abbreviations, or the team_id where a team has no abbreviation or shares
|
|
46
|
+
it with another team of the league (never guessing which team an abbreviation means). With ``teams`` the keys are
|
|
47
|
+
the caller's own values, so they match a seaborn ``hue`` column or a Plotly color column exactly.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
league: The SDV league key, e.g. "nfl".
|
|
51
|
+
teams: Team values to key the dict by; None returns the whole league.
|
|
52
|
+
which: "primary" or "secondary".
|
|
53
|
+
season: One season, or one per team, for values reused across eras.
|
|
54
|
+
id_system: The id system of ``teams``, as in ``resolve``: "auto" tries each in order; NHL stats ids need
|
|
55
|
+
"nhl_id".
|
|
56
|
+
strict: Raise UnresolvedTeamError instead of warning when a team does not resolve.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
dict: ``{team: "#hex"}``. Teams that do not resolve, or have no color, are left out. Without ``teams``, a team
|
|
60
|
+
whose abbreviation another team of the league shares is keyed by its team_id, with one SdvplotWarning naming
|
|
61
|
+
the shared abbreviations.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
TypeError: If ``teams`` is not a scalar, list, tuple, numpy array, or pandas/polars Series.
|
|
65
|
+
InputError: (a ValueError) If ``league`` is not a known league key or ``which`` is not "primary"/"secondary";
|
|
66
|
+
and, when ``teams`` is given, if it holds "primary" or "secondary" (the slot goes in ``which=``),
|
|
67
|
+
``id_system`` is unknown, or ``season`` is not a year, is outside the seasons sdvplot knows for the league,
|
|
68
|
+
or is a list whose length does not match the teams. Without ``teams`` they are not checked.
|
|
69
|
+
UnresolvedTeamError: (a ValueError) If ``strict=True`` and a team does not resolve.
|
|
70
|
+
|
|
71
|
+
Example:
|
|
72
|
+
::
|
|
73
|
+
|
|
74
|
+
import sdvplot
|
|
75
|
+
|
|
76
|
+
sdvplot.palette("nfl", teams=["KC", "SF"]) # {'KC': '#e31837', 'SF': '#aa0000'}
|
|
77
|
+
|
|
78
|
+
See Also:
|
|
79
|
+
sdvplotR: https://sdvplotR.sportsdataverse.org/ ;
|
|
80
|
+
sdv-py: https://py.sportsdataverse.org/
|
|
81
|
+
"""
|
|
82
|
+
column = _column(which)
|
|
83
|
+
_index.check_league(league)
|
|
84
|
+
colors = _colors(league, column)
|
|
85
|
+
if teams is None:
|
|
86
|
+
rows = _index.team_table().filter(pl.col("league") == league).select("team_id", "abbr").rows()
|
|
87
|
+
shared = {a for a, n in Counter(a for _, a in rows if a).items() if n > 1}
|
|
88
|
+
if shared:
|
|
89
|
+
warn(f"abbreviations shared by several {league} teams are keyed by team_id: {', '.join(sorted(shared))}")
|
|
90
|
+
return {(tid if not abbr or abbr in shared else abbr): colors[tid] for tid, abbr in rows if tid in colors}
|
|
91
|
+
values, _ = _unpack(teams)
|
|
92
|
+
if slots := [v for v in values if isinstance(v, str) and v in _COLUMNS]: # pre-0.1: palette(league, which)
|
|
93
|
+
raise InputError(
|
|
94
|
+
f"{slots[0]!r} is a color slot, not a team; pass it by keyword: "
|
|
95
|
+
f'palette(league, teams=..., which="{slots[0]}")'
|
|
96
|
+
)
|
|
97
|
+
pairs = [p for p in dict.fromkeys(zip(values, _seasons(season, len(values)), strict=True)) if p[0] is not None]
|
|
98
|
+
ids = resolve([v for v, _ in pairs], league, season=[s for _, s in pairs], id_system=id_system, strict=strict)
|
|
99
|
+
out: dict[Any, str] = {}
|
|
100
|
+
for (v, _), i in zip(pairs, ids, strict=True):
|
|
101
|
+
if i is not None and i in colors:
|
|
102
|
+
out.setdefault(v, colors[i]) # a value two teams wore across the seasons keeps its first team's color
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# resolve()'s container rule, and so its overloads (the last one's ignore included).
|
|
107
|
+
@overload
|
|
108
|
+
def team_colors(
|
|
109
|
+
league: str,
|
|
110
|
+
teams: str | bytes | int | float | None,
|
|
111
|
+
*,
|
|
112
|
+
which: Which = "primary",
|
|
113
|
+
season: Any = None,
|
|
114
|
+
id_system: IdSystem = "auto",
|
|
115
|
+
strict: bool = False,
|
|
116
|
+
) -> str | None: ...
|
|
117
|
+
@overload
|
|
118
|
+
def team_colors(
|
|
119
|
+
league: str,
|
|
120
|
+
teams: pl.Series,
|
|
121
|
+
*,
|
|
122
|
+
which: Which = "primary",
|
|
123
|
+
season: Any = None,
|
|
124
|
+
id_system: IdSystem = "auto",
|
|
125
|
+
strict: bool = False,
|
|
126
|
+
) -> pl.Series: ...
|
|
127
|
+
@overload
|
|
128
|
+
def team_colors(
|
|
129
|
+
league: str,
|
|
130
|
+
teams: list[Any] | tuple[Any, ...] | np.ndarray[Any, Any],
|
|
131
|
+
*,
|
|
132
|
+
which: Which = "primary",
|
|
133
|
+
season: Any = None,
|
|
134
|
+
id_system: IdSystem = "auto",
|
|
135
|
+
strict: bool = False,
|
|
136
|
+
) -> list[str | None]: ...
|
|
137
|
+
@overload
|
|
138
|
+
def team_colors(
|
|
139
|
+
league: str,
|
|
140
|
+
teams: pd.Series,
|
|
141
|
+
*,
|
|
142
|
+
which: Which = "primary",
|
|
143
|
+
season: Any = None,
|
|
144
|
+
id_system: IdSystem = "auto",
|
|
145
|
+
strict: bool = False,
|
|
146
|
+
) -> pd.Series: ...
|
|
147
|
+
@overload
|
|
148
|
+
def team_colors( # type: ignore[overload-cannot-match]
|
|
149
|
+
league: str,
|
|
150
|
+
teams: Any,
|
|
151
|
+
*,
|
|
152
|
+
which: Which = "primary",
|
|
153
|
+
season: Any = None,
|
|
154
|
+
id_system: IdSystem = "auto",
|
|
155
|
+
strict: bool = False,
|
|
156
|
+
) -> Any: ...
|
|
157
|
+
def team_colors(
|
|
158
|
+
league: str,
|
|
159
|
+
teams: Any,
|
|
160
|
+
*,
|
|
161
|
+
which: Which = "primary",
|
|
162
|
+
season: Any = None,
|
|
163
|
+
id_system: IdSystem = "auto",
|
|
164
|
+
strict: bool = False,
|
|
165
|
+
) -> Any:
|
|
166
|
+
"""One "#hex" (or None) per team value, in the same container the values came in.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
league: The SDV league key, e.g. "nfl".
|
|
170
|
+
teams: A scalar, list/tuple, numpy array, or pandas/polars Series of team identifiers.
|
|
171
|
+
which: "primary" or "secondary".
|
|
172
|
+
season: One season, or one per team, for values reused across eras.
|
|
173
|
+
id_system: The id system of ``teams``, as in ``resolve``: "auto" tries each in order; NHL stats ids need
|
|
174
|
+
"nhl_id".
|
|
175
|
+
strict: Raise UnresolvedTeamError instead of warning when a team does not resolve.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
str | list | Series | None: The hex color for each team, None where a team does not resolve or has no color.
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
TypeError: If ``teams`` is not a scalar, list, tuple, numpy array, or pandas/polars Series.
|
|
182
|
+
InputError: (a ValueError) If ``league`` is not a known league key (a list or Series there is the pre-0.1
|
|
183
|
+
``team_colors(teams, league)`` order), ``which`` is not "primary"/"secondary", ``id_system`` is unknown,
|
|
184
|
+
or ``season`` is not a year, is outside the seasons sdvplot knows for the league, or is a list whose
|
|
185
|
+
length does not match the teams.
|
|
186
|
+
UnresolvedTeamError: (a ValueError) If ``strict=True`` and a team does not resolve.
|
|
187
|
+
|
|
188
|
+
Example:
|
|
189
|
+
::
|
|
190
|
+
|
|
191
|
+
import sdvplot
|
|
192
|
+
|
|
193
|
+
sdvplot.team_colors("nfl", ["KC", "SF"]) # ['#e31837', '#aa0000']
|
|
194
|
+
sdvplot.team_colors("nfl", "KC", which="secondary") # '#ffb612'
|
|
195
|
+
sdvplot.team_colors("nhl", [1, 6, 10], id_system="nhl_id") # NHL stats ids: Devils, Bruins, Leafs
|
|
196
|
+
|
|
197
|
+
See Also:
|
|
198
|
+
sdvplotR: https://sdvplotR.sportsdataverse.org/ ;
|
|
199
|
+
sdv-py: https://py.sportsdataverse.org/
|
|
200
|
+
"""
|
|
201
|
+
column = _column(which)
|
|
202
|
+
_index.check_league(league)
|
|
203
|
+
colors = _colors(league, column)
|
|
204
|
+
values, wrap = _unpack(teams)
|
|
205
|
+
ids = resolve(values, league, season=season, id_system=id_system, strict=strict)
|
|
206
|
+
return wrap([colors.get(i) if i is not None else None for i in ids])
|
sdvplot/_contrast.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Color readability, ported from sdvplotR (R/utils-theme.R): WCAG luminance and contrast, a readable ink, mixing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
_HEX = set("0123456789abcdefABCDEF")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def hex6(color: str, *, drop_alpha: bool = False) -> str:
|
|
9
|
+
"""A color as lowercase ``#rrggbb``: accepts ``#rgb``, ``#rrggbb``, ``#rgba`` and ``#rrggbbaa`` (``#`` optional).
|
|
10
|
+
|
|
11
|
+
Callers draw and measure the result as an opaque color (contrast, ramps, theme accents), so a color with
|
|
12
|
+
transparency is a ValueError rather than silently drawn solid; an opaque alpha (``ff``) is dropped. A caller that
|
|
13
|
+
sets its own alpha passes ``drop_alpha=True`` (as R's ``scales::alpha()`` replaces a color's alpha). Named colors
|
|
14
|
+
and tuples are not hex colors.
|
|
15
|
+
"""
|
|
16
|
+
c = str(color).strip().lstrip("#")
|
|
17
|
+
if len(c) in (3, 4):
|
|
18
|
+
c = "".join(ch * 2 for ch in c)
|
|
19
|
+
if len(c) not in (6, 8) or not set(c) <= _HEX:
|
|
20
|
+
raise ValueError(f"not a hex color: {color!r}")
|
|
21
|
+
if len(c) == 8 and c[6:].lower() != "ff" and not drop_alpha:
|
|
22
|
+
raise ValueError(f"{color!r} has transparency; give an opaque color such as '#{c[:6].lower()}'")
|
|
23
|
+
return "#" + c[:6].lower()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def luminance(color: str) -> float:
|
|
27
|
+
"""WCAG relative luminance, 0 (black) to 1 (white)."""
|
|
28
|
+
c = hex6(color)
|
|
29
|
+
rgb = [int(c[i : i + 2], 16) / 255 for i in (1, 3, 5)]
|
|
30
|
+
lin = [v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4 for v in rgb]
|
|
31
|
+
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def contrast(a: str, b: str) -> float:
|
|
35
|
+
"""WCAG contrast ratio between two colors, 1 (same) to 21 (black on white)."""
|
|
36
|
+
la, lb = luminance(a), luminance(b)
|
|
37
|
+
return (max(la, lb) + 0.05) / (min(la, lb) + 0.05)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def on_color(background: str) -> str:
|
|
41
|
+
"""Black or white, whichever reads better on ``background`` (sdvplotR's ``.theme_on_color``)."""
|
|
42
|
+
return "#000000" if contrast("#000000", background) >= contrast("#ffffff", background) else "#ffffff"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def mix(a: str, b: str, t: float) -> str:
|
|
46
|
+
"""The color ``t`` of the way from ``a`` to ``b`` in sRGB (``t`` in [0, 1])."""
|
|
47
|
+
if not 0 <= t <= 1:
|
|
48
|
+
raise ValueError(f"t must be in [0, 1], got {t!r}")
|
|
49
|
+
ca, cb = hex6(a), hex6(b)
|
|
50
|
+
out = [round(int(ca[i : i + 2], 16) * (1 - t) + int(cb[i : i + 2], 16) * t) for i in (1, 3, 5)]
|
|
51
|
+
return "#" + "".join(f"{v:02x}" for v in out)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def solid(color: str, background: str = "#ffffff") -> str:
|
|
55
|
+
"""The opaque ``#rrggbb`` a hex color shows as over ``background``: a translucent ``#rgba``/``#rrggbbaa`` is
|
|
56
|
+
alpha-composited onto it, so its contrast can be measured; an opaque color is itself."""
|
|
57
|
+
opaque = hex6(color, drop_alpha=True) # ValueError for anything but a hex color
|
|
58
|
+
c = str(color).strip().lstrip("#")
|
|
59
|
+
alpha = int(c[3] * 2, 16) / 255 if len(c) == 4 else int(c[6:], 16) / 255 if len(c) == 8 else 1.0
|
|
60
|
+
return mix(background, opaque, alpha)
|
sdvplot/_court.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Shot locations from the stats.nba.com legacy frame to sportypy's court: the port of sdvplotR's sdv_court_coords()."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import narwhals as nw
|
|
9
|
+
|
|
10
|
+
from sdvplot._placement import _missing
|
|
11
|
+
|
|
12
|
+
BASKET_X = -47 + 5.25 # sportypy's NBA/WNBA/NCAA courts: center-court origin, baseline at -47, basket 5.25 ft in
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _tenths(frame: Any, name: str) -> Any:
|
|
16
|
+
"""Column ``name`` (tenths of a foot) as a Float64 Series; strings of numbers are coerced."""
|
|
17
|
+
s = frame[name]
|
|
18
|
+
if s.dtype.is_numeric(): # rebuilt like the coerced columns, so both outputs share one dtype (pandas nullable too)
|
|
19
|
+
floats = [None if _missing(v) else v for v in s.cast(nw.Float64).to_list()]
|
|
20
|
+
return nw.new_series(name, floats, nw.Float64(), backend=nw.get_native_namespace(frame))
|
|
21
|
+
all_null_bool = s.dtype == nw.Boolean and s.null_count() == len(s) # R/arrow write an all-NA column as logical
|
|
22
|
+
if s.dtype not in (nw.String, nw.Object, nw.Unknown) and not all_null_bool: # Unknown: a polars Null column
|
|
23
|
+
raise TypeError(f"column {name!r} must be numeric or strings of numbers, not {s.dtype}")
|
|
24
|
+
values: list[float | None] = []
|
|
25
|
+
bad: list[Any] = []
|
|
26
|
+
for v in s.to_list():
|
|
27
|
+
if _missing(v):
|
|
28
|
+
values.append(None)
|
|
29
|
+
continue
|
|
30
|
+
if type(v).__name__ in ("bool", "bool_"): # Python and numpy booleans, which float() reads as 1.0 / 0.0
|
|
31
|
+
raise TypeError(f"column {name!r} must be numeric or strings of numbers, not booleans")
|
|
32
|
+
try: # float() reads strings and numbers; R's as.numeric() reads no "_" digit separators, so neither do we
|
|
33
|
+
f = math.nan if isinstance(v, str) and "_" in v else float(v)
|
|
34
|
+
except (TypeError, ValueError):
|
|
35
|
+
f = math.nan
|
|
36
|
+
if math.isnan(f):
|
|
37
|
+
bad.append(v)
|
|
38
|
+
values.append(f)
|
|
39
|
+
if bad:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"column {name!r} has values that are not numbers: {sorted(set(map(str, bad)))}; name the coordinate "
|
|
42
|
+
"columns with x=/y=, or fix the input data"
|
|
43
|
+
)
|
|
44
|
+
return nw.new_series(name, values, nw.Float64(), backend=nw.get_native_namespace(frame))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def court_coords(data: Any, *, x: str = "x_legacy", y: str = "y_legacy") -> Any:
|
|
48
|
+
"""Convert stats.nba.com / stats.wnba.com shot locations to the court frame sportypy draws.
|
|
49
|
+
|
|
50
|
+
The stats API's legacy shot frame (``LOC_X``/``LOC_Y``; hoopR, wehoop and sdv-py ``x_legacy``/``y_legacy``) is in
|
|
51
|
+
tenths of a foot with the hoop at the origin, relative to the shooter's basket. sportypy's ``NBACourt``,
|
|
52
|
+
``WNBACourt`` and ``NCAACourt`` (what ``surface("nba")`` draws) put the origin at center court, the baseline at
|
|
53
|
+
``x = -47`` and the basket at ``x = -41.75``. So ``court_x = -47 + 5.25 + y / 10`` and ``court_y = x / 10``, with no
|
|
54
|
+
sign flip: a left-corner three lands at negative ``court_y``. Every shot falls on the TV-left half; draw a half
|
|
55
|
+
court with ``surface("nba", display_range="defense")``. Don't pass ESPN ``coordinate_x``/``coordinate_y``: they
|
|
56
|
+
are already in feet on a center-court frame.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
data: A pandas or polars DataFrame of shots.
|
|
60
|
+
x: The column holding ``LOC_X`` / ``x_legacy`` (tenths of a foot, across the court).
|
|
61
|
+
y: The column holding ``LOC_Y`` / ``y_legacy`` (tenths of a foot, toward half court).
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
DataFrame: ``data``'s type, with ``court_x`` and ``court_y`` (feet, Float64) added; existing columns of those
|
|
65
|
+
names are replaced in place, every other column is kept. Numeric columns and strings of numbers (stats.nba.com
|
|
66
|
+
payloads arrive as strings) convert; nulls stay null, and an all-null column gives null coordinates.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
TypeError: If ``data`` is not a pandas/polars DataFrame, ``x``/``y`` is not a string, or a coordinate column
|
|
70
|
+
is boolean (or holds booleans), categorical or another non-numeric type.
|
|
71
|
+
ValueError: If ``x`` and ``y`` name the same column, a column is missing, or a string is not a number (such
|
|
72
|
+
as ``""``, ``"NA"`` or ``"1_0"``).
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
::
|
|
76
|
+
|
|
77
|
+
import polars as pl
|
|
78
|
+
import sdvplot
|
|
79
|
+
|
|
80
|
+
shots = pl.DataFrame({"LOC_X": [-224, 240], "LOC_Y": [39, 29]})
|
|
81
|
+
out = sdvplot.court_coords(shots, x="LOC_X", y="LOC_Y")
|
|
82
|
+
out["court_y"].to_list() # [-22.4, 24.0]: a left and a right corner three
|
|
83
|
+
|
|
84
|
+
ax = sdvplot.surface("nba", display_range="defense")
|
|
85
|
+
ax.scatter(out["court_x"], out["court_y"], zorder=20)
|
|
86
|
+
|
|
87
|
+
See Also:
|
|
88
|
+
sdvplotR sdv_court_coords(): https://sdvplotR.sportsdataverse.org/ ;
|
|
89
|
+
sportypy: https://sportypy.sportsdataverse.org/ ;
|
|
90
|
+
nba_api shotchartdetail: https://github.com/swar/nba_api
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
frame = nw.from_native(data, eager_only=True)
|
|
94
|
+
except TypeError:
|
|
95
|
+
raise TypeError(f"data must be a pandas or polars DataFrame, got {type(data).__name__}") from None
|
|
96
|
+
for arg, column in (("x", x), ("y", y)):
|
|
97
|
+
if not isinstance(column, str):
|
|
98
|
+
raise TypeError(f"{arg} must be a single column name (a string), got {column!r}")
|
|
99
|
+
if x == y:
|
|
100
|
+
raise ValueError(f"x and y must name different columns, not both {x!r}")
|
|
101
|
+
missing = [c for c in (x, y) if c not in frame.columns]
|
|
102
|
+
if missing:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"data is missing column(s) {missing}; name the stats-API columns with x=/y=, e.g. x='LOC_X', y='LOC_Y'"
|
|
105
|
+
)
|
|
106
|
+
# Divide by a Series of tens, not the scalar: polars divides by a scalar through its reciprocal, so -224 / 10 would
|
|
107
|
+
# be -22.400000000000002 instead of R's (and IEEE division's) -22.4.
|
|
108
|
+
ten = nw.new_series("ten", [10.0] * len(frame), nw.Float64(), backend=nw.get_native_namespace(frame))
|
|
109
|
+
xs, ys = _tenths(frame, x) / ten, _tenths(frame, y) / ten
|
|
110
|
+
return frame.with_columns(court_x=ys + BASKET_X, court_y=xs).to_native()
|
sdvplot/_deprecate.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Deprecations: one warning category and one way to emit it, so every rename warns the same way (CONTRIBUTING:
|
|
2
|
+
Deprecation policy). Nothing in sdvplot is deprecated yet."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import functools
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any, TypeVar
|
|
9
|
+
|
|
10
|
+
from sdvplot._errors import SdvplotDeprecationWarning, warn
|
|
11
|
+
|
|
12
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def deprecate(old: str, *, replacement: str, removal: str) -> None:
|
|
16
|
+
"""Warn, at the caller's line, that ``old`` is deprecated: it names ``replacement`` and ``removal``, the release
|
|
17
|
+
that removes it. Call it from the deprecated function or branch, which keeps working."""
|
|
18
|
+
warn(f"{old} is deprecated and will be removed in sdvplot {removal}; use {replacement} instead",
|
|
19
|
+
SdvplotDeprecationWarning) # fmt: skip
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def deprecated_alias(*, removal: str, **renames: str) -> Callable[[F], F]:
|
|
23
|
+
"""A decorator for renamed keyword arguments: ``@deprecated_alias(removal="0.3.0", which="kind")`` keeps
|
|
24
|
+
``fn(which=...)`` working as ``fn(kind=...)``, with one warning per call. Passing both names is a TypeError."""
|
|
25
|
+
|
|
26
|
+
def decorate(fn: F) -> F:
|
|
27
|
+
@functools.wraps(fn)
|
|
28
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
29
|
+
for old, new in renames.items():
|
|
30
|
+
if old in kwargs:
|
|
31
|
+
if new in kwargs:
|
|
32
|
+
raise TypeError(f"{fn.__name__}() got both {old}= (deprecated) and {new}=; pass only {new}=")
|
|
33
|
+
deprecate(f"{fn.__name__}({old}=...)", replacement=f"{new}=", removal=removal)
|
|
34
|
+
kwargs[new] = kwargs.pop(old)
|
|
35
|
+
return fn(*args, **kwargs)
|
|
36
|
+
|
|
37
|
+
return wrapper # type: ignore[return-value]
|
|
38
|
+
|
|
39
|
+
return decorate
|