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/_tiers.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""team_tiers' data preparation and look, ported from sdvplotR ``sdv_team_tiers()``: the matplotlib and plotnine
|
|
2
|
+
adapters only draw what ``prepare()`` returns, so ranking, tier lines, labels and limits have one implementation."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import textwrap
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
import narwhals as nw
|
|
12
|
+
|
|
13
|
+
from sdvplot._index import teams as teams_index
|
|
14
|
+
from sdvplot._placement import _missing, _real, _warn_skipped, check_alpha, check_height
|
|
15
|
+
from sdvplot._resolve import _unpack, resolve
|
|
16
|
+
|
|
17
|
+
BG, LINES, MUTED = "#1e1e1e", "#e0e0e0", "#8e8e93" # sdvplotR's dark theme: background, tier lines, subtitle/caption
|
|
18
|
+
# theme -> (background, tier lines, title and labels, subtitle and caption). "light" is sdvplot's: dark logos (Ohio
|
|
19
|
+
# State, Texas A&M, Penn State) vanish on sdvplotR's dark background
|
|
20
|
+
THEMES: dict[str, tuple[str, str, str, str]] = {
|
|
21
|
+
"dark": (BG, LINES, "#ffffff", MUTED),
|
|
22
|
+
"light": ("#ffffff", "#3a3a3c", "#1e1e1e", "#636366"),
|
|
23
|
+
}
|
|
24
|
+
SUBTITLE = "created with the #sdvplot Tiermaker"
|
|
25
|
+
TIER_DESC = {1: "Elite", 2: "Very Good", 3: "Medium", 4: "Bad", 5: "What are they doing?", 6: "", 7: ""}
|
|
26
|
+
# About the largest logo height (a fraction of the panel height) at which 32 square logos in 5 tiers (7, 7, 6, 6,
|
|
27
|
+
# 6) neither overlap nor leave the panel at matplotlib's and plotnine's default 6.4 x 4.8 in figure; it stands in for
|
|
28
|
+
# sdvplotR's width = 0.075 npc. tests/test_team_tiers.py measures it.
|
|
29
|
+
DEFAULT_HEIGHT = 0.1
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Tiers:
|
|
34
|
+
"""What a tier plot draws: one entry per resolved team in x/y/team_ids/labels, then the axes and titles."""
|
|
35
|
+
|
|
36
|
+
x: list[Any] # tier_rank
|
|
37
|
+
y: list[Any] # tier_no
|
|
38
|
+
team_ids: list[str]
|
|
39
|
+
labels: list[str] # the team's abbreviation (else the value given), which devel=True draws
|
|
40
|
+
lines: list[float] # the tier separators, at tier +- 0.5
|
|
41
|
+
breaks: list[Any] # the tiers, top (smallest tier_no) first
|
|
42
|
+
break_labels: list[str]
|
|
43
|
+
xlim: tuple[float, float]
|
|
44
|
+
ylim: tuple[float, float] # (top, bottom) in tier_no units
|
|
45
|
+
title: str
|
|
46
|
+
subtitle: str | None
|
|
47
|
+
caption: str | None
|
|
48
|
+
height: float
|
|
49
|
+
alpha: float
|
|
50
|
+
bg: str # the theme's colors (THEMES)
|
|
51
|
+
line_color: str
|
|
52
|
+
text: str
|
|
53
|
+
muted: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _wrap(text: str) -> str:
|
|
57
|
+
"""R's ``strwrap(text, 15)``: lines under 15 characters, a longer word kept whole."""
|
|
58
|
+
return "\n".join(textwrap.wrap(text, 14, break_long_words=False, break_on_hyphens=False))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def prepare(
|
|
62
|
+
data: Any,
|
|
63
|
+
league: str,
|
|
64
|
+
*,
|
|
65
|
+
title: str | None = None,
|
|
66
|
+
subtitle: str | None = SUBTITLE,
|
|
67
|
+
caption: str | None = None,
|
|
68
|
+
tier_desc: dict[Any, str] | None = None,
|
|
69
|
+
presort: bool = False,
|
|
70
|
+
alpha: float = 0.8,
|
|
71
|
+
height: float | None = None,
|
|
72
|
+
no_line_below_tier: Any = None,
|
|
73
|
+
theme: Literal["dark", "light"] = "dark",
|
|
74
|
+
) -> Tiers:
|
|
75
|
+
"""Validate ``data`` and compute everything a tier plot draws (see the adapters' ``team_tiers``)."""
|
|
76
|
+
if theme not in THEMES:
|
|
77
|
+
raise ValueError(f"theme must be one of {sorted(THEMES)}, got {theme!r}")
|
|
78
|
+
frame = nw.from_native(data, eager_only=True) # TypeError unless a pandas/polars (narwhals-supported) frame
|
|
79
|
+
missing = [c for c in ("tier_no", "team") if c not in frame.columns]
|
|
80
|
+
if missing:
|
|
81
|
+
raise ValueError(f"data must have the columns tier_no and team; missing {', '.join(missing)}")
|
|
82
|
+
h = DEFAULT_HEIGHT if height is None else check_height(height)
|
|
83
|
+
a = check_alpha(alpha)
|
|
84
|
+
tiers, teams = frame["tier_no"].to_list(), frame["team"].to_list()
|
|
85
|
+
# a pandas column with a NaN is float: 1.0 back to 1, so tier_desc's R-style "1" keys (and the ticks) match
|
|
86
|
+
tiers = [int(t) if isinstance(t, float) and t.is_integer() else t for t in tiers]
|
|
87
|
+
given = frame["tier_rank"].to_list() if "tier_rank" in frame.columns and not presort else None
|
|
88
|
+
|
|
89
|
+
absent = {i for i, t in enumerate(tiers) if _missing(t) or (given is not None and _missing(given[i]))}
|
|
90
|
+
_warn_skipped("with a missing tier_no or tier_rank", [teams[i] for i in sorted(absent)])
|
|
91
|
+
rows = [i for i in range(len(tiers)) if i not in absent]
|
|
92
|
+
if not rows:
|
|
93
|
+
raise ValueError("data has no rows with a tier_no")
|
|
94
|
+
if not all(_real(tiers[i]) and (given is None or _real(given[i])) for i in rows):
|
|
95
|
+
raise TypeError("tier_no and tier_rank must hold numbers (tier 1 is the top tier)")
|
|
96
|
+
if presort: # sdvplotR: arrange(tier_no, team), then rank within the tier
|
|
97
|
+
rows.sort(key=lambda i: (tiers[i], _missing(teams[i]), str(teams[i]))) # a missing team last, as R's NA
|
|
98
|
+
if given is None:
|
|
99
|
+
seen: Counter[Any] = Counter()
|
|
100
|
+
ranks = {}
|
|
101
|
+
for i in rows:
|
|
102
|
+
seen[tiers[i]] += 1
|
|
103
|
+
ranks[i] = seen[tiers[i]]
|
|
104
|
+
else:
|
|
105
|
+
ranks = {i: given[i] for i in rows}
|
|
106
|
+
|
|
107
|
+
# Rank first, then resolve, as sdvplotR does: an unknown team keeps its slot (and the x range) but draws nothing.
|
|
108
|
+
ids = resolve([teams[i] for i in rows], league)
|
|
109
|
+
kept = [(i, team_id) for i, team_id in zip(rows, ids, strict=True) if team_id is not None]
|
|
110
|
+
abbr = dict(teams_index(league).select("team_id", "abbr").iter_rows()) # devel draws it, as sdvplotR does
|
|
111
|
+
levels = sorted({tiers[i] for i in rows})
|
|
112
|
+
skip = set(_unpack(no_line_below_tier)[0]) if no_line_below_tier is not None else set()
|
|
113
|
+
desc: dict[Any, str] = TIER_DESC if tier_desc is None else tier_desc
|
|
114
|
+
lo, hi = min(ranks.values()), max(ranks.values())
|
|
115
|
+
pad = 0.5 if lo == hi else 0.05 * (hi - lo) # a ggplot continuous scale's default expansion
|
|
116
|
+
return Tiers(
|
|
117
|
+
x=[ranks[i] for i, _ in kept],
|
|
118
|
+
y=[tiers[i] for i, _ in kept],
|
|
119
|
+
team_ids=[team_id for _, team_id in kept],
|
|
120
|
+
labels=[abbr.get(team_id) or str(teams[i]) for i, team_id in kept],
|
|
121
|
+
lines=[levels[0] - 0.5] + [t + 0.5 for t in levels if t not in skip],
|
|
122
|
+
breaks=levels,
|
|
123
|
+
break_labels=[_wrap(desc.get(t, desc.get(str(t), ""))) for t in levels],
|
|
124
|
+
xlim=(lo - pad, hi + pad),
|
|
125
|
+
ylim=(levels[0] - 0.6, levels[-1] + 0.6), # +- 0.5 around the tiers, plus sdvplotR's expansion(add = 0.1)
|
|
126
|
+
title=f"{league.upper()} Team Tiers" if title is None else title,
|
|
127
|
+
subtitle=subtitle,
|
|
128
|
+
caption=caption,
|
|
129
|
+
height=h,
|
|
130
|
+
alpha=a,
|
|
131
|
+
bg=THEMES[theme][0],
|
|
132
|
+
line_color=THEMES[theme][1],
|
|
133
|
+
text=THEMES[theme][2],
|
|
134
|
+
muted=THEMES[theme][3],
|
|
135
|
+
)
|
sdvplot/_types.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""The closed vocabularies of sdvplot's arguments, as ``Literal`` types a type checker can check.
|
|
2
|
+
|
|
3
|
+
An alias exists only where the runtime accepts exactly that set of strings; ``tests/test_types.py`` keeps each one
|
|
4
|
+
equal to the set the code validates against. Two vocabularies stay ``str`` because they are open: ``league`` (the
|
|
5
|
+
leagues come from the bundled team index, which grows with each release) and ``variant`` (the names come from the logo
|
|
6
|
+
archive's manifest; "default" and "dark" always work).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
# resolve()'s id_system: "auto", then _resolve.PRIORITY in order, then _resolve.EXPLICIT_ONLY
|
|
12
|
+
IdSystem = Literal[
|
|
13
|
+
"auto",
|
|
14
|
+
"team_id",
|
|
15
|
+
"espn",
|
|
16
|
+
"espn_abbr",
|
|
17
|
+
"nhl",
|
|
18
|
+
"nflverse",
|
|
19
|
+
"mlbstats",
|
|
20
|
+
"nba_api",
|
|
21
|
+
"hockeytech",
|
|
22
|
+
"ncaa",
|
|
23
|
+
"pff",
|
|
24
|
+
"cricinfo",
|
|
25
|
+
"cfbd",
|
|
26
|
+
"bref",
|
|
27
|
+
"sportsipy",
|
|
28
|
+
"fangraphs",
|
|
29
|
+
"sdvplotr",
|
|
30
|
+
"name",
|
|
31
|
+
"nhl_id",
|
|
32
|
+
]
|
|
33
|
+
# headshot_url()'s id_system: ESPN athlete ids (any ESPN league) or nflverse gsis ids (nfl)
|
|
34
|
+
HeadshotIdSystem = Literal["espn", "gsis"]
|
|
35
|
+
# palette()'s color slot: _colors._COLUMNS
|
|
36
|
+
Which = Literal["primary", "secondary"]
|
|
37
|
+
# logo_url() and logo_image()'s mark: _marks.MARK_TYPES
|
|
38
|
+
MarkType = Literal["logo", "wordmark"]
|
sdvplot/_versions.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""What a bug report needs: package version, bundled-index version, and the cached manifest's date."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sdvplot._cache import read_meta
|
|
6
|
+
from sdvplot._index import index_version
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def versions() -> dict[str, str | None]:
|
|
10
|
+
"""What a bug report needs: the package version, the bundled-index version, and the cached manifest's date.
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
dict[str, str | None]: Keys ``sdvplot``, ``index`` and ``manifest_last_modified`` (None until the manifest has
|
|
14
|
+
been downloaded).
|
|
15
|
+
|
|
16
|
+
Example:
|
|
17
|
+
::
|
|
18
|
+
|
|
19
|
+
import sdvplot
|
|
20
|
+
|
|
21
|
+
sdvplot.versions()["sdvplot"] # '0.1.0'
|
|
22
|
+
|
|
23
|
+
See Also:
|
|
24
|
+
sdvplotR: https://sdvplotR.sportsdataverse.org/ ;
|
|
25
|
+
sdv-py: https://py.sportsdataverse.org/
|
|
26
|
+
"""
|
|
27
|
+
from sdvplot import __version__
|
|
28
|
+
|
|
29
|
+
meta = read_meta("manifest/marks.csv") or {}
|
|
30
|
+
return {"sdvplot": __version__, "index": index_version(), "manifest_last_modified": meta.get("last_modified")}
|
sdvplot/_web.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""What the web adapters (Plotly, Altair, Bokeh, HoloViews, Folium, pygal) share: image sources and aspect ratios.
|
|
2
|
+
|
|
3
|
+
Browsers load images themselves, so a web adapter hands over the mark's ``archive_url`` (immutable, content-addressed),
|
|
4
|
+
or with ``embed=True`` a ``data:`` URI of the cached bytes, for HTML that renders offline and for static export.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
from PIL import Image
|
|
13
|
+
|
|
14
|
+
from sdvplot._images import mark_file, url_file
|
|
15
|
+
from sdvplot._placement import Placement
|
|
16
|
+
|
|
17
|
+
# ESPN's combiner serves full headshots at 600x436 (measured 2026-10-04 for NFL, NBA and MLB ids); checked by
|
|
18
|
+
# tests/test_web_live.py.
|
|
19
|
+
HEADSHOT_ASPECT = 600 / 436
|
|
20
|
+
_URL_PUNCTUATION = "-._~:/?#[]@!$&()*+,;=%" # SAFE_URL's characters besides letters and digits (quote keeps those)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def aspect(p: Placement) -> float:
|
|
24
|
+
"""Width / height of the placement's image: the manifest's, or HEADSHOT_ASPECT for a headshot."""
|
|
25
|
+
return p.aspect if p.aspect is not None else HEADSHOT_ASPECT
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def axis_letter(axis: str) -> str:
|
|
29
|
+
"""``axis`` if it is "x" or "y", else a ValueError (the check every web adapter's axis verb starts with)."""
|
|
30
|
+
if axis not in ("x", "y"):
|
|
31
|
+
raise ValueError(f"axis must be 'x' or 'y', got {axis!r}")
|
|
32
|
+
return axis
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def image_src(p: Placement, *, embed: bool = False) -> str:
|
|
36
|
+
"""The image source for a browser: ``p.url``, or with ``embed=True`` a data URI of the cached image bytes.
|
|
37
|
+
|
|
38
|
+
Embedding reads through the cache (downloading on a miss), so offline with no cached copy it raises OfflineError.
|
|
39
|
+
The URL is percent-encoded past ``SAFE_URL``'s characters, a no-op on the URLs the manifest and player table let
|
|
40
|
+
through, so an unvalidated one still cannot close the <script> block or attribute an adapter writes it into.
|
|
41
|
+
"""
|
|
42
|
+
if not embed:
|
|
43
|
+
return quote(p.url, safe=_URL_PUNCTUATION)
|
|
44
|
+
path = mark_file(p.mark) if p.mark is not None else url_file(p.url)
|
|
45
|
+
if p.mark is not None and str(p.mark["ext"]).lower() == "svg":
|
|
46
|
+
mime = "image/svg+xml"
|
|
47
|
+
else:
|
|
48
|
+
with Image.open(path) as img:
|
|
49
|
+
mime = Image.MIME.get(img.format or "", "application/octet-stream")
|
|
50
|
+
return f"data:{mime};base64,{base64.b64encode(path.read_bytes()).decode('ascii')}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def image_sources(placements: list[Placement], *, embed: bool = False) -> list[str]:
|
|
54
|
+
"""``image_src`` for each placement, reading each distinct image once."""
|
|
55
|
+
seen: dict[str, str] = {}
|
|
56
|
+
out = []
|
|
57
|
+
for p in placements:
|
|
58
|
+
if p.url not in seen:
|
|
59
|
+
seen[p.url] = image_src(p, embed=embed)
|
|
60
|
+
out.append(seen[p.url])
|
|
61
|
+
return out
|