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
|
@@ -0,0 +1,1802 @@
|
|
|
1
|
+
"""Legends, layout and annotation helpers for great_tables, ported from sdvplotR's ``gt_*`` functions (wave C2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import dataclasses
|
|
7
|
+
import datetime
|
|
8
|
+
import functools
|
|
9
|
+
import importlib.metadata
|
|
10
|
+
import math
|
|
11
|
+
import numbers
|
|
12
|
+
import random
|
|
13
|
+
import string
|
|
14
|
+
import textwrap
|
|
15
|
+
from collections.abc import Mapping, Sequence
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Literal
|
|
18
|
+
from urllib.parse import quote
|
|
19
|
+
|
|
20
|
+
import faicons
|
|
21
|
+
import narwhals as nw
|
|
22
|
+
import polars as pl
|
|
23
|
+
from great_tables import GT, google_font, html, loc
|
|
24
|
+
from great_tables import style as gst
|
|
25
|
+
|
|
26
|
+
from sdvplot._contrast import contrast, hex6, mix, on_color, solid
|
|
27
|
+
from sdvplot._errors import warn
|
|
28
|
+
from sdvplot.great_tables._cells import _columns, _frame, _row_indices
|
|
29
|
+
from sdvplot.great_tables._export import _css_len, _fonts, _style, _style_css
|
|
30
|
+
from sdvplot.great_tables._marks import (
|
|
31
|
+
_background,
|
|
32
|
+
_check_gt,
|
|
33
|
+
_record,
|
|
34
|
+
_secondary_on,
|
|
35
|
+
_table_id,
|
|
36
|
+
important,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
_MIME = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".svg": "image/svg+xml", ".gif": "image/gif"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _with_fonts(gt: GT, fonts: list[str], location: Any) -> GT:
|
|
43
|
+
"""Load Google fonts through great_tables, so the inline ``font-family`` references resolve."""
|
|
44
|
+
for font in fonts:
|
|
45
|
+
gt = gt.tab_style(gst.text(font=google_font(font)), location)
|
|
46
|
+
return gt
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _choice(name: str, value: str, options: tuple[str, ...]) -> str:
|
|
50
|
+
"""R's ``match.arg``: ``value`` must be one of ``options``."""
|
|
51
|
+
if value not in options:
|
|
52
|
+
raise ValueError(f"{name} must be one of {', '.join(map(repr, options))}, got {value!r}")
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _number(value: Any) -> float | None:
|
|
57
|
+
"""R's ``as.numeric``: a float, or ``None`` for missing and non-numeric values (None, NaN, pd.NA, "abc")."""
|
|
58
|
+
try:
|
|
59
|
+
x = float(value)
|
|
60
|
+
except (TypeError, ValueError):
|
|
61
|
+
return None
|
|
62
|
+
return None if math.isnan(x) else x
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _numbers(gt: GT, column: str) -> list[float | None]:
|
|
66
|
+
return [_number(v) for v in _frame(gt)[column].to_list()]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _strings(gt: GT, column: str) -> list[str | None]:
|
|
70
|
+
"""A column's values as strings, ``None`` where missing (None, NaN in pandas, pd.NA)."""
|
|
71
|
+
series = _frame(gt)[column]
|
|
72
|
+
return [None if null else str(v) for v, null in zip(series.to_list(), series.is_null().to_list(), strict=True)]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _palette(palette: Any) -> list[str]:
|
|
76
|
+
"""A palette as a list of ``#rrggbb`` colors (sdvplotR also takes paletteer names, which are R-only)."""
|
|
77
|
+
if isinstance(palette, str):
|
|
78
|
+
raise ValueError(f"palette must be a list of hex colors, not the string {palette!r}")
|
|
79
|
+
colors = [hex6(c) for c in palette]
|
|
80
|
+
if not colors:
|
|
81
|
+
raise ValueError("palette is empty")
|
|
82
|
+
return colors
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _ramp(palette: list[str], t: float) -> str:
|
|
86
|
+
"""The color ``t`` (0-1) of the way along ``palette``: piecewise-linear sRGB between evenly spaced stops, the
|
|
87
|
+
ramp great_tables' ``data_color`` draws."""
|
|
88
|
+
if len(palette) == 1:
|
|
89
|
+
return palette[0]
|
|
90
|
+
pos = min(max(t, 0.0), 1.0) * (len(palette) - 1)
|
|
91
|
+
i = min(int(pos), len(palette) - 2)
|
|
92
|
+
return mix(palette[i], palette[i + 1], pos - i)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _text(value: Any) -> str | None:
|
|
96
|
+
"""A heading part (``str``, great_tables ``Html``/``Md`` or ``None``) as its text; ``None`` when empty."""
|
|
97
|
+
text = getattr(value, "text", value)
|
|
98
|
+
return str(text) if text is not None and str(text) else None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _in_header(gt: GT, block: str, fonts: list[str]) -> GT:
|
|
102
|
+
"""Put ``block`` at the top: as the title if the table has none, else under the title and subtitle."""
|
|
103
|
+
heading = gt._heading
|
|
104
|
+
old_title, old_subtitle = _text(heading.title), _text(heading.subtitle)
|
|
105
|
+
if old_title is None:
|
|
106
|
+
return _with_fonts(gt.tab_header(title=html(block), preheader=heading.preheader), fonts, loc.title())
|
|
107
|
+
spacer = "" if old_subtitle is None else '<div style="height:4px;"></div>'
|
|
108
|
+
out = gt.tab_header(
|
|
109
|
+
title=html(old_title), subtitle=html((old_subtitle or "") + spacer + block), preheader=heading.preheader
|
|
110
|
+
)
|
|
111
|
+
return _with_fonts(out, fonts, loc.subtitle())
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
_JUSTIFY = {"left": "flex-start", "right": "flex-end", "center": "center"}
|
|
115
|
+
_RANK_PALETTE = ("#3D8B6E", "#9DC5A7", "#EDE0CC", "#DB9070", "#BE4D3A")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def gt_title_header(
|
|
119
|
+
gt: GT,
|
|
120
|
+
title: str,
|
|
121
|
+
*,
|
|
122
|
+
subtitle: str | None = None,
|
|
123
|
+
kicker: str | None = None,
|
|
124
|
+
date: datetime.date | str | None = None,
|
|
125
|
+
kicker_style: Mapping[str, Any] | None = None,
|
|
126
|
+
title_style: Mapping[str, Any] | None = None,
|
|
127
|
+
subtitle_style: Mapping[str, Any] | None = None,
|
|
128
|
+
date_style: Mapping[str, Any] | None = None,
|
|
129
|
+
) -> GT:
|
|
130
|
+
"""Add a styled header: an optional kicker line, the title, the subtitle and a date line.
|
|
131
|
+
|
|
132
|
+
Each element takes a style dict with the keys ``font`` (a Google font name), ``size``, ``color``, ``weight``,
|
|
133
|
+
``italic``, ``spacing`` (letter spacing), ``transform`` (e.g. ``"uppercase"``), ``align``, ``line_height``,
|
|
134
|
+
``margin_top``, ``margin_bottom``, ``padding_top`` and ``padding_bottom``. A number is read as pixels; a string is
|
|
135
|
+
any CSS length.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
gt: The table.
|
|
139
|
+
title: The title text (HTML allowed).
|
|
140
|
+
subtitle: The subtitle text.
|
|
141
|
+
kicker: A short line above the title, uppercase red by default.
|
|
142
|
+
date: A date under the subtitle; a ``datetime.date`` prints as "July 21, 2026", anything else as given.
|
|
143
|
+
kicker_style: Styles the kicker.
|
|
144
|
+
title_style: Styles the title.
|
|
145
|
+
subtitle_style: Styles the subtitle.
|
|
146
|
+
date_style: Styles the date (small gray by default).
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
GT: A new table whose header holds the styled block (it replaces any existing header).
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
TypeError: If ``gt`` is not a great_tables ``GT``.
|
|
153
|
+
ValueError: If a style dict has an unknown key.
|
|
154
|
+
|
|
155
|
+
Example:
|
|
156
|
+
::
|
|
157
|
+
|
|
158
|
+
import datetime
|
|
159
|
+
import polars as pl
|
|
160
|
+
from great_tables import GT
|
|
161
|
+
from sdvplot.great_tables import gt_title_header
|
|
162
|
+
|
|
163
|
+
gt = gt_title_header(GT(pl.DataFrame({"team": ["LV"]})), "Week 5", subtitle="Power ranking",
|
|
164
|
+
kicker="NFL", date=datetime.date(2026, 10, 4))
|
|
165
|
+
|
|
166
|
+
See Also:
|
|
167
|
+
Ported from sdvplotR ``gt_title_header()``: https://sdvplotR.sportsdataverse.org/reference/gt_title_header.html
|
|
168
|
+
"""
|
|
169
|
+
_check_gt(gt)
|
|
170
|
+
s_kicker = _style(
|
|
171
|
+
"kicker_style",
|
|
172
|
+
kicker_style,
|
|
173
|
+
{"size": "0.75em", "weight": 700, "color": "#C84630", "transform": "uppercase", "spacing": "0.08em"},
|
|
174
|
+
)
|
|
175
|
+
s_title = _style("title_style", title_style)
|
|
176
|
+
s_subtitle = _style("subtitle_style", subtitle_style)
|
|
177
|
+
s_date = _style("date_style", date_style, {"size": "0.85em", "weight": 400, "color": "#8A8A8A"})
|
|
178
|
+
|
|
179
|
+
kicker_html = "" if kicker is None else f'<div style="{_style_css(s_kicker)}margin-bottom:0.15em;">{kicker}</div>'
|
|
180
|
+
title_full = f'{kicker_html}<div style="{_style_css(s_title)}">{title}</div>'
|
|
181
|
+
subtitle_html = "" if subtitle is None else f'<div style="{_style_css(s_subtitle)}">{subtitle}</div>'
|
|
182
|
+
date_html = ""
|
|
183
|
+
if date is not None:
|
|
184
|
+
shown = date.strftime("%B %d, %Y") if isinstance(date, datetime.date) else str(date)
|
|
185
|
+
date_html = f'<div style="{_style_css(s_date)}margin-top:0.15em;">{shown}</div>'
|
|
186
|
+
subtitle_full = subtitle_html + date_html
|
|
187
|
+
|
|
188
|
+
out = gt.tab_header(title=html(title_full), subtitle=html(subtitle_full) if subtitle_full else None)
|
|
189
|
+
return _with_fonts(out, _fonts(s_kicker, s_title, s_subtitle, s_date), loc.title())
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def gt_set_font(
|
|
193
|
+
gt: GT,
|
|
194
|
+
font_family: str,
|
|
195
|
+
*,
|
|
196
|
+
from_google_font: bool = True,
|
|
197
|
+
weight: str | int | None = None,
|
|
198
|
+
style: str | None = None,
|
|
199
|
+
) -> GT:
|
|
200
|
+
"""Set one font family (and optionally a weight and style) on every part of the table.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
gt: The table.
|
|
204
|
+
font_family: The font family.
|
|
205
|
+
from_google_font: Load ``font_family`` from Google Fonts; ``False`` uses a font the viewer has installed.
|
|
206
|
+
weight: A font weight for every part (``"bold"`` or a number such as 600); ``None`` leaves it alone.
|
|
207
|
+
style: ``"normal"``, ``"italic"`` or ``"oblique"``; ``None`` leaves it alone.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
GT: A new table with the font set on the title, stubhead, spanners, column labels, row groups, stub, body,
|
|
211
|
+
footnotes and source notes (summary rows are left alone, as in sdvplotR).
|
|
212
|
+
|
|
213
|
+
Raises:
|
|
214
|
+
TypeError: If ``gt`` is not a great_tables ``GT``.
|
|
215
|
+
|
|
216
|
+
Example:
|
|
217
|
+
::
|
|
218
|
+
|
|
219
|
+
import polars as pl
|
|
220
|
+
from great_tables import GT
|
|
221
|
+
from sdvplot.great_tables import gt_set_font
|
|
222
|
+
|
|
223
|
+
gt = gt_set_font(GT(pl.DataFrame({"team": ["LV"]})), "Roboto Condensed", weight=600)
|
|
224
|
+
|
|
225
|
+
See Also:
|
|
226
|
+
Ported from sdvplotR ``gt_set_font()``: https://sdvplotR.sportsdataverse.org/reference/gt_set_font.html
|
|
227
|
+
"""
|
|
228
|
+
_check_gt(gt)
|
|
229
|
+
family = google_font(font_family) if from_google_font else font_family
|
|
230
|
+
locations = [
|
|
231
|
+
loc.title(),
|
|
232
|
+
loc.subtitle(),
|
|
233
|
+
loc.stubhead(),
|
|
234
|
+
loc.spanner_labels(ids=[s.spanner_id for s in gt._spanners]),
|
|
235
|
+
loc.column_labels(),
|
|
236
|
+
loc.row_groups(),
|
|
237
|
+
loc.stub(),
|
|
238
|
+
loc.body(),
|
|
239
|
+
loc.footnotes(),
|
|
240
|
+
loc.source_notes(),
|
|
241
|
+
]
|
|
242
|
+
# great_tables types weight and style as keyword Literals, but writes any value into the CSS (weight 600 verified);
|
|
243
|
+
# Any-typed rather than a type: ignore, which mypy calls unused when great_tables is not installed
|
|
244
|
+
text: dict[str, Any] = {"font": family, "weight": weight, "style": style}
|
|
245
|
+
return gt.tab_style(gst.text(**text), locations)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _watermark_svg(text: str, color: str, opacity: float, angle: float, font: str) -> tuple[str, bool]:
|
|
249
|
+
"""An inline SVG text mark as a ``data:`` URI, and whether the rotated box is taller than wide."""
|
|
250
|
+
label = str(text).replace("&", "&").replace("<", "<").replace(">", ">")
|
|
251
|
+
size = 100
|
|
252
|
+
text_w = max(len(label) * size * 0.62, size)
|
|
253
|
+
text_h = size * 1.3
|
|
254
|
+
rad = abs(angle) * math.pi / 180
|
|
255
|
+
w = math.ceil(text_w * math.cos(rad) + text_h * math.sin(rad)) + 4
|
|
256
|
+
h = math.ceil(text_w * math.sin(rad) + text_h * math.cos(rad)) + 4
|
|
257
|
+
rot = f' transform="rotate({angle:g} {w / 2:g} {h / 2:g})"' if angle != 0 else ""
|
|
258
|
+
# attribute values: a font list such as '"Helvetica Neue", Arial' must not end font-family="..." early
|
|
259
|
+
font, color = (str(v).replace("&", "&").replace("<", "<").replace('"', """) for v in (font, color))
|
|
260
|
+
svg = (
|
|
261
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}">'
|
|
262
|
+
f'<text x="50%" y="50%" text-anchor="middle" dominant-baseline="central" font-family="{font}" '
|
|
263
|
+
f'font-size="{size}" font-weight="700" fill="{color}" fill-opacity="{opacity:g}"{rot}>{label}</text></svg>'
|
|
264
|
+
)
|
|
265
|
+
return "data:image/svg+xml," + quote(svg, safe=""), h > w
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def gt_watermark(
|
|
269
|
+
gt: GT,
|
|
270
|
+
text: str | None = None,
|
|
271
|
+
*,
|
|
272
|
+
image: str | Path | None = None,
|
|
273
|
+
opacity: float = 0.06,
|
|
274
|
+
size: str = "60%",
|
|
275
|
+
position: str = "center",
|
|
276
|
+
color: str = "#000000",
|
|
277
|
+
angle: float = 0,
|
|
278
|
+
font: str = "Helvetica, Arial, sans-serif",
|
|
279
|
+
) -> GT:
|
|
280
|
+
"""Put a faint text or image watermark behind the table body.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
gt: The table.
|
|
284
|
+
text: Text to draw as the watermark (an inline SVG, so ``font`` must be a font the viewer has).
|
|
285
|
+
image: Instead of ``text``, a PNG, JPEG, SVG or GIF file, embedded as a ``data:`` URI.
|
|
286
|
+
opacity: How faint the watermark is, 0 to 1.
|
|
287
|
+
size: The watermark's size relative to the table body, as a CSS background size.
|
|
288
|
+
position: Where it sits, as a CSS background position (``"center"``, ``"right bottom"``).
|
|
289
|
+
color: The text color (``text`` only).
|
|
290
|
+
angle: Rotation in degrees (``text`` only).
|
|
291
|
+
font: The font family for ``text``.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
GT: A new table with the watermark as the body's CSS background (the table gets an id if it had none).
|
|
295
|
+
|
|
296
|
+
Raises:
|
|
297
|
+
TypeError: If ``gt`` is not a great_tables ``GT``.
|
|
298
|
+
ValueError: If neither or both of ``text`` and ``image`` are given, or ``image`` is not a supported type.
|
|
299
|
+
FileNotFoundError: If ``image`` does not exist.
|
|
300
|
+
|
|
301
|
+
Example:
|
|
302
|
+
::
|
|
303
|
+
|
|
304
|
+
import polars as pl
|
|
305
|
+
from great_tables import GT
|
|
306
|
+
from sdvplot.great_tables import gt_watermark
|
|
307
|
+
|
|
308
|
+
gt = gt_watermark(GT(pl.DataFrame({"team": ["LV"]})), text="DRAFT", angle=-30)
|
|
309
|
+
|
|
310
|
+
See Also:
|
|
311
|
+
Ported from sdvplotR ``gt_watermark()``: https://sdvplotR.sportsdataverse.org/reference/gt_watermark.html
|
|
312
|
+
"""
|
|
313
|
+
_check_gt(gt)
|
|
314
|
+
if (text is None) == (image is None):
|
|
315
|
+
raise ValueError("supply exactly one of text or image")
|
|
316
|
+
gt, table_id = _table_id(gt)
|
|
317
|
+
tall = False
|
|
318
|
+
extra = ""
|
|
319
|
+
if image is None:
|
|
320
|
+
uri, tall = _watermark_svg(str(text), color, opacity, angle, font)
|
|
321
|
+
else:
|
|
322
|
+
path = Path(image)
|
|
323
|
+
if not path.exists():
|
|
324
|
+
raise FileNotFoundError(f"can't find the watermark image {path}")
|
|
325
|
+
mime = _MIME.get(path.suffix.lower())
|
|
326
|
+
if mime is None:
|
|
327
|
+
raise ValueError(f"image must be a PNG, JPEG, SVG or GIF, not {path.name}")
|
|
328
|
+
uri = f"data:{mime};base64," + base64.b64encode(path.read_bytes()).decode("ascii")
|
|
329
|
+
extra = f" opacity: {opacity:g};"
|
|
330
|
+
fit = f"auto {size}" if tall else f"{size} auto"
|
|
331
|
+
return gt.opt_css(
|
|
332
|
+
f"#{table_id} .gt_table_body {{ background-image: url('{uri}'); background-repeat: no-repeat;"
|
|
333
|
+
f" background-position: {position}; background-size: {fit};{extra} }}"
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def gt_legend_continuous(
|
|
338
|
+
gt: GT,
|
|
339
|
+
columns: Any = None,
|
|
340
|
+
*,
|
|
341
|
+
palette: Sequence[str] | None = None,
|
|
342
|
+
domain: Sequence[float] | None = None,
|
|
343
|
+
reverse: bool | None = None,
|
|
344
|
+
pal_type: str | None = None,
|
|
345
|
+
type: str = "continuous",
|
|
346
|
+
n_bins: int = 5,
|
|
347
|
+
labels: str | Sequence[str] | None = None,
|
|
348
|
+
digits: int = 0,
|
|
349
|
+
title: str | None = None,
|
|
350
|
+
title_position: str = "top",
|
|
351
|
+
title_style: Mapping[str, Any] | None = None,
|
|
352
|
+
labels_style: Mapping[str, Any] | None = None,
|
|
353
|
+
labels_position: str = "bottom",
|
|
354
|
+
location: str = "bottom",
|
|
355
|
+
align: str = "center",
|
|
356
|
+
width: float = 200,
|
|
357
|
+
height: float = 10,
|
|
358
|
+
border_color: str | None = None,
|
|
359
|
+
border_width: float = 1,
|
|
360
|
+
radius: float = 2,
|
|
361
|
+
gap: float = 3,
|
|
362
|
+
title_gap: float = 4,
|
|
363
|
+
block_gap: float = 2,
|
|
364
|
+
) -> GT:
|
|
365
|
+
"""Add a color-scale legend that matches a column colored by ``gt_color_ranks``, ``gt_color_pills``,
|
|
366
|
+
``gt_percentile_bar`` or ``GT.data_color``.
|
|
367
|
+
|
|
368
|
+
Those three sdvplot functions record the scale they used on the table (``_sdvplot_scale``); every argument left
|
|
369
|
+
as ``None`` here is taken from that record, so ``gt_legend_continuous(gt)`` cannot disagree with the cells. The
|
|
370
|
+
bar is a row of solid segments (CSS gradients do not survive every renderer), colored by the same piecewise-linear
|
|
371
|
+
ramp great_tables' ``data_color`` uses. Style dicts take the keys listed in ``gt_title_header``.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
gt: The table.
|
|
375
|
+
columns: The column selection the legend describes, used to derive ``domain``. Defaults to the recorded one.
|
|
376
|
+
palette: Hex colors spread over ``domain``. Defaults to the recorded palette, else sdvplotR's five-color
|
|
377
|
+
green-to-red ramp.
|
|
378
|
+
domain: ``(low, high)``. Defaults to the recorded domain, else the range of ``columns``.
|
|
379
|
+
reverse: Reverse the palette. Defaults to the recorded value, else ``False``.
|
|
380
|
+
pal_type: ``"discrete"`` or ``"continuous"``; kept for sdvplotR parity (it picks a paletteer registry in R).
|
|
381
|
+
type: ``"continuous"`` (a smooth ramp), ``"steps"`` (``n_bins`` touching steps) or ``"blocks"`` (separated).
|
|
382
|
+
n_bins: The number of steps or blocks.
|
|
383
|
+
labels: ``None`` labels the two ends of ``domain``; ``"edges"`` labels the ``n_bins + 1`` bin edges; a list
|
|
384
|
+
of strings is spread evenly.
|
|
385
|
+
digits: Decimal places of derived labels.
|
|
386
|
+
title: A caption for the legend.
|
|
387
|
+
title_position: ``"top"``, ``"bottom"``, ``"left"`` or ``"right"`` of the bar.
|
|
388
|
+
title_style: Styles the title (default 11px gray).
|
|
389
|
+
labels_style: Styles the labels (default 10px gray).
|
|
390
|
+
labels_position: ``"bottom"``, ``"top"`` or ``"none"``.
|
|
391
|
+
location: ``"bottom"`` adds the legend as a source note; ``"top"`` puts it in the header, under any title.
|
|
392
|
+
align: ``"center"``, ``"left"`` or ``"right"``.
|
|
393
|
+
width: The bar width in pixels.
|
|
394
|
+
height: The bar height in pixels.
|
|
395
|
+
border_color: A border around the bar (each block, for ``"blocks"``).
|
|
396
|
+
border_width: The border width in pixels.
|
|
397
|
+
radius: The corner radius in pixels.
|
|
398
|
+
gap: Pixels between the bar and its labels.
|
|
399
|
+
title_gap: Pixels between the title and the bar.
|
|
400
|
+
block_gap: Pixels between blocks.
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
GT: A new table with the legend added.
|
|
404
|
+
|
|
405
|
+
Raises:
|
|
406
|
+
TypeError: If ``gt`` is not a great_tables ``GT``.
|
|
407
|
+
ValueError: If neither a domain nor numeric ``columns`` can be found, an option is not one of its choices,
|
|
408
|
+
``n_bins`` is below 1, or a palette color is not hex.
|
|
409
|
+
|
|
410
|
+
Example:
|
|
411
|
+
::
|
|
412
|
+
|
|
413
|
+
import polars as pl
|
|
414
|
+
from great_tables import GT
|
|
415
|
+
from sdvplot.great_tables import gt_legend_continuous, gt_percentile_bar
|
|
416
|
+
|
|
417
|
+
gt = gt_percentile_bar(GT(pl.DataFrame({"pct": [94, 41, 72]})), "pct")
|
|
418
|
+
gt = gt_legend_continuous(gt, title="Percentile") # palette and domain come from the bars
|
|
419
|
+
|
|
420
|
+
See Also:
|
|
421
|
+
Ported from sdvplotR ``gt_legend_continuous()``:
|
|
422
|
+
https://sdvplotR.sportsdataverse.org/reference/gt_legend_continuous.html
|
|
423
|
+
"""
|
|
424
|
+
_check_gt(gt)
|
|
425
|
+
_choice("type", type, ("continuous", "steps", "blocks"))
|
|
426
|
+
_choice("title_position", title_position, ("top", "bottom", "left", "right"))
|
|
427
|
+
_choice("labels_position", labels_position, ("bottom", "top", "none"))
|
|
428
|
+
_choice("location", location, ("bottom", "top"))
|
|
429
|
+
_choice("align", align, ("center", "left", "right"))
|
|
430
|
+
if n_bins < 1:
|
|
431
|
+
raise ValueError("n_bins must be at least 1")
|
|
432
|
+
|
|
433
|
+
rec = gt.__dict__.get("_sdvplot_scale") or {}
|
|
434
|
+
columns = rec.get("columns") if columns is None else columns
|
|
435
|
+
palette = rec.get("palette", _RANK_PALETTE) if palette is None else palette
|
|
436
|
+
domain = rec.get("domain") if domain is None else domain
|
|
437
|
+
reverse = rec.get("reverse", False) if reverse is None else reverse
|
|
438
|
+
pal = _palette(palette)[:: -1 if reverse else 1]
|
|
439
|
+
|
|
440
|
+
if domain is None:
|
|
441
|
+
if columns is None:
|
|
442
|
+
raise ValueError(
|
|
443
|
+
"need columns or domain to know what the legend spans; color the table with gt_color_ranks, "
|
|
444
|
+
"gt_color_pills or gt_percentile_bar first and the legend picks the scale up"
|
|
445
|
+
)
|
|
446
|
+
values = [v for c in _columns(gt, columns) for v in _numbers(gt, c) if v is not None]
|
|
447
|
+
if not values:
|
|
448
|
+
raise ValueError("no numeric values found in columns; pass domain directly")
|
|
449
|
+
domain = (min(values), max(values))
|
|
450
|
+
if len(domain) != 2:
|
|
451
|
+
raise ValueError(f"domain must be (low, high), got {domain!r}")
|
|
452
|
+
lo, hi = float(domain[0]), float(domain[1])
|
|
453
|
+
|
|
454
|
+
s_title = _style("title_style", title_style, {"size": "11px", "color": "#666666"})
|
|
455
|
+
s_labels = _style("labels_style", labels_style, {"size": "10px", "color": "#666666"})
|
|
456
|
+
|
|
457
|
+
if labels is None:
|
|
458
|
+
texts = [f"{v:,.{digits}f}" for v in (lo, hi)]
|
|
459
|
+
elif isinstance(labels, str) and labels == "edges":
|
|
460
|
+
texts = [f"{lo + (hi - lo) * k / n_bins:,.{digits}f}" for k in range(n_bins + 1)]
|
|
461
|
+
else:
|
|
462
|
+
texts = [str(x) for x in ([labels] if isinstance(labels, str) else labels)]
|
|
463
|
+
|
|
464
|
+
border = "" if border_color is None else f"border:{border_width:g}px solid {border_color};"
|
|
465
|
+
if type == "continuous":
|
|
466
|
+
colors = [_ramp(pal, k / 59) for k in range(60)]
|
|
467
|
+
else:
|
|
468
|
+
colors = [_ramp(pal, (k + 0.5) / n_bins) for k in range(n_bins)]
|
|
469
|
+
if type == "blocks":
|
|
470
|
+
segments = "".join(
|
|
471
|
+
f'<span style="flex:1 0 auto; height:{height:g}px; background-color:{c}; border-radius:{radius:g}px; '
|
|
472
|
+
f'{border}"></span>'
|
|
473
|
+
for c in colors
|
|
474
|
+
)
|
|
475
|
+
bar = f'<div style="display:flex; width:{width:g}px; gap:{block_gap:g}px;">{segments}</div>'
|
|
476
|
+
else:
|
|
477
|
+
segments = "".join(f'<span style="flex:1 0 auto; background-color:{c};"></span>' for c in colors)
|
|
478
|
+
bar = (
|
|
479
|
+
f'<div style="display:flex; width:{width:g}px; height:{height:g}px; border-radius:{radius:g}px; '
|
|
480
|
+
f'overflow:hidden; {border}">{segments}</div>'
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
labels_html = ""
|
|
484
|
+
if labels_position != "none" and len(texts) == 1:
|
|
485
|
+
labels_html = f'<div style="width:{width:g}px; {_style_css(s_labels)} text-align:center;">{texts[0]}</div>'
|
|
486
|
+
elif labels_position != "none" and texts:
|
|
487
|
+
spans = "".join(f"<span>{t}</span>" for t in texts)
|
|
488
|
+
labels_html = (
|
|
489
|
+
f'<div style="display:flex; width:{width:g}px; justify-content:space-between; {_style_css(s_labels)}">'
|
|
490
|
+
f"{spans}</div>"
|
|
491
|
+
)
|
|
492
|
+
stacked = labels_html + bar if labels_position == "top" else bar + labels_html
|
|
493
|
+
legend = f'<div style="display:flex; flex-direction:column; gap:{gap:g}px;">{stacked}</div>'
|
|
494
|
+
|
|
495
|
+
if title is not None:
|
|
496
|
+
title_html = f'<div style="{_style_css(s_title)}">{title}</div>'
|
|
497
|
+
if title_position in ("top", "bottom"):
|
|
498
|
+
inner = title_html + legend if title_position == "top" else legend + title_html
|
|
499
|
+
legend = (
|
|
500
|
+
f'<div style="display:flex; flex-direction:column; align-items:{_JUSTIFY[align]}; '
|
|
501
|
+
f'gap:{title_gap:g}px;">{inner}</div>'
|
|
502
|
+
)
|
|
503
|
+
else:
|
|
504
|
+
inner = title_html + legend if title_position == "left" else legend + title_html
|
|
505
|
+
legend = (
|
|
506
|
+
f'<div style="display:flex; flex-direction:row; align-items:center; gap:{title_gap:g}px;">{inner}</div>'
|
|
507
|
+
)
|
|
508
|
+
legend = f'<div style="display:flex; justify-content:{_JUSTIFY[align]};">{legend}</div>'
|
|
509
|
+
|
|
510
|
+
fonts = _fonts(s_title, s_labels)
|
|
511
|
+
if location == "bottom":
|
|
512
|
+
return _with_fonts(gt.tab_source_note(html(legend)), fonts, loc.source_notes())
|
|
513
|
+
return _in_header(gt, legend, fonts)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def gt_legend_discrete(
|
|
517
|
+
gt: GT,
|
|
518
|
+
key_info: Any = None,
|
|
519
|
+
*,
|
|
520
|
+
heading: str | None = None,
|
|
521
|
+
subtitle: str | None = None,
|
|
522
|
+
label_placement: str = "outside",
|
|
523
|
+
location: str = "top",
|
|
524
|
+
shape: str = "square",
|
|
525
|
+
swatch_size: float = 14,
|
|
526
|
+
border: bool = True,
|
|
527
|
+
border_color: str | None = None,
|
|
528
|
+
border_width: float = 1,
|
|
529
|
+
gap: float = 14,
|
|
530
|
+
direction: str = "horizontal",
|
|
531
|
+
align: str = "center",
|
|
532
|
+
heading_style: Mapping[str, Any] | None = None,
|
|
533
|
+
subtitle_style: Mapping[str, Any] | None = None,
|
|
534
|
+
label_style: Mapping[str, Any] | None = None,
|
|
535
|
+
) -> GT:
|
|
536
|
+
"""Add a key of labeled color swatches (home/away, tiers, conferences).
|
|
537
|
+
|
|
538
|
+
Text colors are read off the table background, so the key stays legible on a dark theme. Style dicts take the
|
|
539
|
+
keys listed in ``gt_title_header``.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
gt: The table.
|
|
543
|
+
key_info: ``{label: hex color}``, or a pandas/polars frame with ``color`` and ``label`` columns (or color then
|
|
544
|
+
label as its first two columns). Defaults to the key ``gt_tiers`` recorded on the table.
|
|
545
|
+
heading: A heading above the key.
|
|
546
|
+
subtitle: A subtitle under the heading.
|
|
547
|
+
label_placement: ``"outside"`` (label beside its swatch) or ``"inside"`` (label printed on the swatch).
|
|
548
|
+
location: ``"top"`` (the header) or ``"bottom"`` (a source note).
|
|
549
|
+
shape: ``"square"``, ``"rounded"`` or ``"circle"``.
|
|
550
|
+
swatch_size: The swatch size in pixels.
|
|
551
|
+
border: Draw a hairline around each swatch.
|
|
552
|
+
border_color: The hairline color; defaults to a darker shade of each swatch.
|
|
553
|
+
border_width: The hairline width in pixels.
|
|
554
|
+
gap: Pixels between keys.
|
|
555
|
+
direction: ``"horizontal"`` (a row) or ``"vertical"`` (a column).
|
|
556
|
+
align: ``"center"``, ``"left"`` or ``"right"``.
|
|
557
|
+
heading_style: Styles the heading (16px, weight 600, ink on the table background).
|
|
558
|
+
subtitle_style: Styles the subtitle (13px, a muted ink).
|
|
559
|
+
label_style: Styles the labels (12px).
|
|
560
|
+
|
|
561
|
+
Returns:
|
|
562
|
+
GT: A new table with the key added. With ``location="top"`` and a heading or subtitle, the key replaces the
|
|
563
|
+
header; a bare key goes under any existing title and subtitle.
|
|
564
|
+
|
|
565
|
+
Raises:
|
|
566
|
+
TypeError: If ``gt`` is not a great_tables ``GT`` or ``key_info`` is neither a mapping nor a data frame.
|
|
567
|
+
ValueError: If there is no key, a color is not hex, or an option is not one of its choices.
|
|
568
|
+
|
|
569
|
+
Example:
|
|
570
|
+
::
|
|
571
|
+
|
|
572
|
+
import polars as pl
|
|
573
|
+
from great_tables import GT
|
|
574
|
+
from sdvplot.great_tables import gt_legend_discrete
|
|
575
|
+
|
|
576
|
+
gt = gt_legend_discrete(GT(pl.DataFrame({"game": ["@ KC"]})), {"Home": "#cce7f5", "Away": "#eeeeee"})
|
|
577
|
+
|
|
578
|
+
See Also:
|
|
579
|
+
Ported from sdvplotR ``gt_legend_discrete()``:
|
|
580
|
+
https://sdvplotR.sportsdataverse.org/reference/gt_legend_discrete.html
|
|
581
|
+
"""
|
|
582
|
+
_check_gt(gt)
|
|
583
|
+
_choice("label_placement", label_placement, ("outside", "inside"))
|
|
584
|
+
_choice("location", location, ("top", "bottom"))
|
|
585
|
+
_choice("shape", shape, ("square", "rounded", "circle"))
|
|
586
|
+
_choice("direction", direction, ("horizontal", "vertical"))
|
|
587
|
+
_choice("align", align, ("center", "left", "right"))
|
|
588
|
+
|
|
589
|
+
if key_info is None:
|
|
590
|
+
key_info = gt.__dict__.get("_sdvplot_key")
|
|
591
|
+
if key_info is None:
|
|
592
|
+
raise ValueError("key_info is missing and no key is recorded on the table; pass {'Home': '#cce7f5'} or call "
|
|
593
|
+
"gt_tiers first") # fmt: skip
|
|
594
|
+
if isinstance(key_info, Mapping):
|
|
595
|
+
labels, colors = [str(k) for k in key_info], [str(v) for v in key_info.values()]
|
|
596
|
+
else:
|
|
597
|
+
try:
|
|
598
|
+
key = nw.from_native(key_info, eager_only=True)
|
|
599
|
+
except TypeError:
|
|
600
|
+
raise TypeError("key_info must be a mapping of label to color or a data frame") from None
|
|
601
|
+
names = key.columns
|
|
602
|
+
if {"color", "label"} <= set(names):
|
|
603
|
+
colors, labels = key["color"].to_list(), key["label"].to_list()
|
|
604
|
+
elif len(names) >= 2:
|
|
605
|
+
colors, labels = key[names[0]].to_list(), key[names[1]].to_list()
|
|
606
|
+
else:
|
|
607
|
+
raise ValueError("a data frame key_info needs a color and a label column")
|
|
608
|
+
colors, labels = [str(c) for c in colors], [str(x) for x in labels]
|
|
609
|
+
colors = [hex6(c) for c in colors]
|
|
610
|
+
|
|
611
|
+
bg = _background(gt)
|
|
612
|
+
ink = on_color(bg)
|
|
613
|
+
s_heading = _style("heading_style", heading_style, {"size": 16, "weight": 600, "color": ink})
|
|
614
|
+
s_subtitle = _style("subtitle_style", subtitle_style, {"size": 13, "weight": 400, "color": _secondary_on(bg, ink)})
|
|
615
|
+
s_label = _style("label_style", label_style, {"size": 12, "color": ink})
|
|
616
|
+
|
|
617
|
+
edges = [mix(c, "#000000", 0.18) if border_color is None else border_color for c in colors]
|
|
618
|
+
rims = [f"border:{border_width:g}px solid {e};" if border else "" for e in edges]
|
|
619
|
+
radius = {"square": 0, "rounded": round(swatch_size / 4), "circle": round(swatch_size / 2)}[shape]
|
|
620
|
+
|
|
621
|
+
if label_placement == "inside":
|
|
622
|
+
items = [
|
|
623
|
+
f'<span style="display:inline-block; padding:2px 9px; background-color:{c}; border-radius:{radius}px; '
|
|
624
|
+
f'{rim} {_style_css({**s_label, "color": on_color(c)})}">{label}</span>'
|
|
625
|
+
for c, rim, label in zip(colors, rims, labels, strict=True)
|
|
626
|
+
]
|
|
627
|
+
else:
|
|
628
|
+
items = [
|
|
629
|
+
'<span style="display:inline-flex; align-items:center; gap:6px;">'
|
|
630
|
+
f'<span style="display:inline-block; width:{swatch_size:g}px; height:{swatch_size:g}px; '
|
|
631
|
+
f'background-color:{c}; border-radius:{radius}px; {rim}"></span>'
|
|
632
|
+
f'<span style="{_style_css(s_label)}">{label}</span></span>'
|
|
633
|
+
for c, rim, label in zip(colors, rims, labels, strict=True)
|
|
634
|
+
]
|
|
635
|
+
justify = _JUSTIFY[align]
|
|
636
|
+
flow = (
|
|
637
|
+
f"column; align-items:{justify};"
|
|
638
|
+
if direction == "vertical"
|
|
639
|
+
else f"row; justify-content:{justify}; align-items:center;"
|
|
640
|
+
)
|
|
641
|
+
key_html = f'<div style="display:flex; flex-wrap:wrap; gap:{gap:g}px; flex-direction:{flow}">{"".join(items)}</div>'
|
|
642
|
+
head = ""
|
|
643
|
+
if heading is not None:
|
|
644
|
+
head += f'<div style="{_style_css(s_heading)}">{heading}</div>'
|
|
645
|
+
if subtitle is not None:
|
|
646
|
+
head += f'<div style="{_style_css(s_subtitle)}">{subtitle}</div>'
|
|
647
|
+
content = (
|
|
648
|
+
f'<div style="display:flex; flex-direction:column; align-items:{justify}; gap:6px;">{head}{key_html}</div>'
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
fonts = _fonts(s_heading, s_subtitle, s_label)
|
|
652
|
+
if location == "bottom":
|
|
653
|
+
return _with_fonts(gt.tab_source_note(html(content)), fonts, loc.source_notes())
|
|
654
|
+
if heading is not None or subtitle is not None:
|
|
655
|
+
return _with_fonts(gt.tab_header(title=html(content)), fonts, loc.title())
|
|
656
|
+
return _in_header(gt, content, fonts)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def gt_percentile_bar(
|
|
660
|
+
gt: GT,
|
|
661
|
+
columns: Any,
|
|
662
|
+
*,
|
|
663
|
+
rows: Any = None,
|
|
664
|
+
domain: Sequence[float] = (0, 100),
|
|
665
|
+
scale: str | float = "auto",
|
|
666
|
+
palette: Sequence[str] = ("#3661AD", "#C9C9C9", "#D22D49"),
|
|
667
|
+
reverse: bool = False,
|
|
668
|
+
pal_type: str = "discrete",
|
|
669
|
+
track_color: str = "#E9E9E9",
|
|
670
|
+
track_height: float = 6,
|
|
671
|
+
marker_size: float = 22,
|
|
672
|
+
text_color: str = "#FFFFFF",
|
|
673
|
+
font_size: float | None = None,
|
|
674
|
+
ring_color: str | None = None,
|
|
675
|
+
ring_width: float = 2,
|
|
676
|
+
full_track: bool = True,
|
|
677
|
+
na_label: str | None = "—",
|
|
678
|
+
na_track_color: str | None = None,
|
|
679
|
+
na_text_color: str = "#9A9A9A",
|
|
680
|
+
decimals: int = 0,
|
|
681
|
+
width: float | None = 220,
|
|
682
|
+
) -> GT:
|
|
683
|
+
"""Draw each percentile as a filled track with a round marker at its tip, the value printed in the marker.
|
|
684
|
+
|
|
685
|
+
All CSS, so it stays sharp at any export scale. The fill and marker take the palette color mapped from the value.
|
|
686
|
+
The scale is recorded on the table (``_sdvplot_scale``), so ``gt_legend_continuous(gt)`` matches it.
|
|
687
|
+
|
|
688
|
+
Args:
|
|
689
|
+
gt: The table.
|
|
690
|
+
columns: The columns holding percentiles (any great_tables column selection).
|
|
691
|
+
rows: The rows to draw bars in (any great_tables row selection: 0-based positions, a polars expression, or a
|
|
692
|
+
function of the pandas frame); other rows keep their value. Defaults to every row.
|
|
693
|
+
domain: ``(low, high)`` of the percentile scale.
|
|
694
|
+
scale: ``"auto"`` treats a column whose values all lie in [0, 1] as proportions of ``domain`` (0.72 is drawn
|
|
695
|
+
and printed as 72); ``"none"`` leaves values alone; a number multiplies every value.
|
|
696
|
+
palette: Hex colors mapped across ``domain`` (blue, gray, red).
|
|
697
|
+
reverse: Reverse the palette.
|
|
698
|
+
pal_type: ``"discrete"`` or ``"continuous"``; kept for sdvplotR parity and recorded with the scale.
|
|
699
|
+
track_color: The unfilled track color.
|
|
700
|
+
track_height: The track thickness in pixels.
|
|
701
|
+
marker_size: The marker diameter in pixels.
|
|
702
|
+
text_color: The number's color.
|
|
703
|
+
font_size: The number's size in pixels; defaults to half of ``marker_size``.
|
|
704
|
+
ring_color: A ring around the marker; ``None`` for none.
|
|
705
|
+
ring_width: The ring thickness in pixels.
|
|
706
|
+
full_track: Run the track the full width (else it stops at the marker).
|
|
707
|
+
na_label: What a missing percentile shows, centered in a broken track; ``None`` draws an unbroken empty track.
|
|
708
|
+
na_track_color: The track color of missing rows; defaults to ``track_color``.
|
|
709
|
+
na_text_color: The color of ``na_label``.
|
|
710
|
+
decimals: Decimal places of the number.
|
|
711
|
+
width: The column width in pixels; ``None`` leaves it alone.
|
|
712
|
+
|
|
713
|
+
Returns:
|
|
714
|
+
GT: A new table with the bars (unchanged, with an SdvplotWarning, when ``rows`` selects no rows).
|
|
715
|
+
|
|
716
|
+
Raises:
|
|
717
|
+
TypeError: If ``gt`` is not a great_tables ``GT``.
|
|
718
|
+
ValueError: If ``scale``, ``pal_type`` or ``domain`` is invalid, or a palette color is not hex.
|
|
719
|
+
|
|
720
|
+
Example:
|
|
721
|
+
::
|
|
722
|
+
|
|
723
|
+
import polars as pl
|
|
724
|
+
from great_tables import GT
|
|
725
|
+
from sdvplot.great_tables import gt_percentile_bar
|
|
726
|
+
|
|
727
|
+
df = pl.DataFrame({"metric": ["Barrel %", "Chase rate"], "pct": [94, None]})
|
|
728
|
+
gt = gt_percentile_bar(GT(df), "pct", na_label="Not qualified")
|
|
729
|
+
|
|
730
|
+
See Also:
|
|
731
|
+
Ported from sdvplotR ``gt_percentile_bar()``: https://sdvplotR.sportsdataverse.org/reference/gt_percentile_bar.html
|
|
732
|
+
"""
|
|
733
|
+
_check_gt(gt)
|
|
734
|
+
_choice("pal_type", pal_type, ("discrete", "continuous"))
|
|
735
|
+
if isinstance(scale, str):
|
|
736
|
+
_choice("scale", scale, ("auto", "none"))
|
|
737
|
+
if len(domain) != 2 or domain[1] == domain[0]:
|
|
738
|
+
raise ValueError(f"domain must be (low, high) with low != high, got {domain!r}")
|
|
739
|
+
cols = _columns(gt, columns)
|
|
740
|
+
if not cols:
|
|
741
|
+
return gt
|
|
742
|
+
keep = _row_indices(gt, rows)
|
|
743
|
+
if not keep:
|
|
744
|
+
warn("rows matched no rows; the table is unchanged")
|
|
745
|
+
return gt
|
|
746
|
+
|
|
747
|
+
lo, hi = float(domain[0]), float(domain[1])
|
|
748
|
+
pal = _palette(palette)[:: -1 if reverse else 1]
|
|
749
|
+
size = round(marker_size * 0.5, 1) if font_size is None else font_size
|
|
750
|
+
half, r, row_h = marker_size / 2, track_height / 2, marker_size + 4
|
|
751
|
+
na_track = track_color if na_track_color is None else na_track_color
|
|
752
|
+
|
|
753
|
+
def na_cell() -> str:
|
|
754
|
+
seg = (
|
|
755
|
+
f'<div style="flex:1; height:{track_height:.2f}px; border-radius:{r:.2f}px; background:{na_track};"></div>'
|
|
756
|
+
)
|
|
757
|
+
inner = seg
|
|
758
|
+
if na_label is not None:
|
|
759
|
+
label = (
|
|
760
|
+
f'<span style="font-size:{size:.1f}px; color:{na_text_color}; letter-spacing:0.06em; '
|
|
761
|
+
f'white-space:nowrap; line-height:1;">{na_label}</span>'
|
|
762
|
+
)
|
|
763
|
+
inner = seg + label + seg
|
|
764
|
+
return (
|
|
765
|
+
f'<div style="display:flex; align-items:center; gap:8px; height:{row_h:.2f}px; padding:0 {half:.2f}px;">'
|
|
766
|
+
f"{inner}</div>"
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
def bar(value: Any, proportion: bool) -> str:
|
|
770
|
+
v = _number(value)
|
|
771
|
+
if v is None:
|
|
772
|
+
return na_cell()
|
|
773
|
+
if not isinstance(scale, str):
|
|
774
|
+
v *= scale
|
|
775
|
+
elif proportion:
|
|
776
|
+
v = lo + v * (hi - lo)
|
|
777
|
+
frac = min(1.0, max(0.0, (v - lo) / (hi - lo)))
|
|
778
|
+
color = _ramp(pal, frac)
|
|
779
|
+
at = f"calc({half:.2f}px + {frac:.4f} * (100% - {marker_size:.2f}px))"
|
|
780
|
+
centered = "position:absolute; top:50%; transform:translateY(-50%);"
|
|
781
|
+
track = (
|
|
782
|
+
f'<div style="{centered} left:{half:.2f}px; right:{half:.2f}px; height:{track_height:.2f}px; '
|
|
783
|
+
f'border-radius:{r:.2f}px; background:{track_color};"></div>'
|
|
784
|
+
)
|
|
785
|
+
fill = (
|
|
786
|
+
f'<div style="{centered} left:{half:.2f}px; width:calc({at} - {half:.2f}px); height:{track_height:.2f}px; '
|
|
787
|
+
f'border-radius:{r:.2f}px; background:{color};"></div>'
|
|
788
|
+
)
|
|
789
|
+
ring = "" if ring_color is None else f" box-shadow:0 0 0 {ring_width:.2f}px {ring_color};"
|
|
790
|
+
marker = (
|
|
791
|
+
f'<div style="position:absolute; top:50%; left:{at}; transform:translate(-50%,-50%); '
|
|
792
|
+
f"width:{marker_size:.2f}px; height:{marker_size:.2f}px; border-radius:50%; background:{color}; "
|
|
793
|
+
f"color:{text_color}; font-size:{size:.1f}px; font-weight:700; line-height:{marker_size:.2f}px; "
|
|
794
|
+
f'text-align:center;{ring}">{v:.{decimals}f}</div>'
|
|
795
|
+
)
|
|
796
|
+
return (
|
|
797
|
+
f'<div style="position:relative; width:100%; height:{row_h:g}px;">'
|
|
798
|
+
f"{track if full_track else ''}{fill}{marker}</div>"
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
for col in cols:
|
|
802
|
+
present = [v for v in _numbers(gt, col) if v is not None]
|
|
803
|
+
proportion = scale == "auto" and bool(present) and all(0 <= v <= 1 for v in present) and hi > 1
|
|
804
|
+
# partial binds this column's proportion now; a closure would see the last column's by render time
|
|
805
|
+
gt = gt.fmt(functools.partial(bar, proportion=proportion), columns=col, rows=keep)
|
|
806
|
+
if width is not None:
|
|
807
|
+
gt = gt.cols_width(cases={c: f"{width:g}px" for c in cols})
|
|
808
|
+
scale_record = {"columns": cols, "palette": list(palette), "domain": (lo, hi), "reverse": reverse}
|
|
809
|
+
return _record(gt, "_sdvplot_scale", {**scale_record, "pal_type": pal_type})
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def gt_tiers(
|
|
813
|
+
gt: GT,
|
|
814
|
+
levels: Mapping[str, str] | Sequence[str],
|
|
815
|
+
colors: Sequence[str] | None = None,
|
|
816
|
+
*,
|
|
817
|
+
style: str = "dark",
|
|
818
|
+
img_height: str = "55px",
|
|
819
|
+
tier_column: str = "tier",
|
|
820
|
+
image_columns: Any = None,
|
|
821
|
+
) -> GT:
|
|
822
|
+
"""Build a tier list: a tier label column filled in each tier's color, the other columns rendered as images.
|
|
823
|
+
|
|
824
|
+
Applies ``gt_theme_tier(style=style)``, renders the image columns with ``fmt_image`` at ``img_height``, blanks
|
|
825
|
+
missing cells and every column label, then fills each tier's label cell with its color and readable bold ink. The
|
|
826
|
+
tier colors are recorded on the table (``_sdvplot_key``), so ``gt_legend_discrete(gt)`` draws the matching key.
|
|
827
|
+
|
|
828
|
+
Args:
|
|
829
|
+
gt: The table: one row per tier, a tier column, and image paths or URLs in the other columns.
|
|
830
|
+
levels: The tier values, in order; or one ``{level: hex color}`` mapping, leaving ``colors`` unset.
|
|
831
|
+
colors: Hex colors paired with ``levels``.
|
|
832
|
+
style: ``"dark"`` or ``"light"``, passed to ``gt_theme_tier``.
|
|
833
|
+
img_height: The image height, as a CSS size.
|
|
834
|
+
tier_column: The column holding the tier values.
|
|
835
|
+
image_columns: The columns to render as images (any great_tables selection); defaults to every other column.
|
|
836
|
+
|
|
837
|
+
Returns:
|
|
838
|
+
GT: A new tier-list table.
|
|
839
|
+
|
|
840
|
+
Raises:
|
|
841
|
+
TypeError: If ``gt`` is not a great_tables ``GT``.
|
|
842
|
+
ValueError: If ``colors`` is missing without a mapping, the lengths differ, ``tier_column`` is not a column,
|
|
843
|
+
or a color is not hex.
|
|
844
|
+
|
|
845
|
+
Warns:
|
|
846
|
+
SdvplotWarning: When a level has no rows in ``tier_column``.
|
|
847
|
+
|
|
848
|
+
Example:
|
|
849
|
+
::
|
|
850
|
+
|
|
851
|
+
import polars as pl
|
|
852
|
+
from great_tables import GT
|
|
853
|
+
from sdvplot.great_tables import gt_tiers
|
|
854
|
+
|
|
855
|
+
df = pl.DataFrame({"tier": ["S", "A"], "logo": ["https://.../kc.png", "https://.../buf.png"]})
|
|
856
|
+
gt = gt_tiers(GT(df), {"S": "#C84630", "A": "#5DA271"})
|
|
857
|
+
|
|
858
|
+
See Also:
|
|
859
|
+
Ported from sdvplotR ``gt_tiers()``: https://sdvplotR.sportsdataverse.org/reference/gt_tiers.html
|
|
860
|
+
"""
|
|
861
|
+
from sdvplot.great_tables._themes import gt_theme_tier # wave B
|
|
862
|
+
|
|
863
|
+
_check_gt(gt)
|
|
864
|
+
if colors is None:
|
|
865
|
+
if not isinstance(levels, Mapping):
|
|
866
|
+
raise ValueError("colors is missing; pass levels and colors as two lists, or one mapping such as "
|
|
867
|
+
"{'A': '#C84630', 'B': '#5DA271'}") # fmt: skip
|
|
868
|
+
colors = list(levels.values())
|
|
869
|
+
names = [str(x) for x in levels]
|
|
870
|
+
if len(names) != len(colors):
|
|
871
|
+
raise ValueError(f"levels and colors must be the same length: got {len(names)} levels and {len(colors)} colors")
|
|
872
|
+
fills = [hex6(c) for c in colors]
|
|
873
|
+
|
|
874
|
+
data = _frame(gt)
|
|
875
|
+
if tier_column not in data.columns:
|
|
876
|
+
raise ValueError(f"tier_column {tier_column!r} is not a column in the table; it has {data.columns}")
|
|
877
|
+
tiers = _strings(gt, tier_column)
|
|
878
|
+
missing = [x for x in names if x not in tiers]
|
|
879
|
+
if missing:
|
|
880
|
+
held = list(dict.fromkeys(t for t in tiers if t is not None))
|
|
881
|
+
warn(f"tier(s) {missing} are not in {tier_column!r}, so they get no rows; it holds {held}")
|
|
882
|
+
|
|
883
|
+
chosen = data.columns if image_columns is None else _columns(gt, image_columns)
|
|
884
|
+
images = [c for c in chosen if c != tier_column]
|
|
885
|
+
gt = (
|
|
886
|
+
gt_theme_tier(gt, style=style)
|
|
887
|
+
.fmt_image(columns=images, height=img_height)
|
|
888
|
+
.sub_missing(missing_text="")
|
|
889
|
+
.cols_label(cases={c: "" for c in data.columns})
|
|
890
|
+
)
|
|
891
|
+
for level, fill in zip(names, fills, strict=True):
|
|
892
|
+
rows = [i for i, t in enumerate(tiers) if t == level]
|
|
893
|
+
if rows:
|
|
894
|
+
look = [important(gst.fill(color=fill)), important(gst.text(weight="bold", color=on_color(fill)))]
|
|
895
|
+
gt = gt.tab_style(look, loc.body(columns=tier_column, rows=rows))
|
|
896
|
+
return _record(gt, "_sdvplot_key", dict(zip(names, fills, strict=True)))
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def _rendered(gt: GT) -> list[str]:
|
|
900
|
+
"""The visible body columns, in display order (not the stub, row groups or hidden columns)."""
|
|
901
|
+
return [c.var for c in gt._boxhead if c.type.name == "default"]
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _append(text: str, suffix: str) -> str:
|
|
905
|
+
"""A ``text_transform`` function: the cell's rendered text plus ``suffix`` (bound with ``functools.partial``)."""
|
|
906
|
+
return f"{text}{suffix}"
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def gt_spotlight(
|
|
910
|
+
gt: GT,
|
|
911
|
+
rows: Any,
|
|
912
|
+
*,
|
|
913
|
+
columns: Any = None,
|
|
914
|
+
fill: str | None = None,
|
|
915
|
+
text_color: str | None = None,
|
|
916
|
+
bold: bool = True,
|
|
917
|
+
accent_color: str | None = None,
|
|
918
|
+
accent_width: float = 4,
|
|
919
|
+
accent_column: Any = None,
|
|
920
|
+
dim_color: str | None = "#BBBBBB",
|
|
921
|
+
if_none: str = "warn",
|
|
922
|
+
) -> GT:
|
|
923
|
+
"""Light up some rows (bold, a fill, an accent bar) and dim everything else.
|
|
924
|
+
|
|
925
|
+
Args:
|
|
926
|
+
gt: The table.
|
|
927
|
+
rows: The rows to focus on: any great_tables row selection (0-based positions, a polars expression, or a
|
|
928
|
+
function of the pandas frame).
|
|
929
|
+
columns: The columns the spotlight covers; cells outside it are dimmed in the focused rows too. Defaults to
|
|
930
|
+
every column.
|
|
931
|
+
fill: A fill behind the focused cells.
|
|
932
|
+
text_color: The focused cells' text color.
|
|
933
|
+
bold: Bold the focused cells.
|
|
934
|
+
accent_color: A bar on the left edge of the focused rows; giving a color turns it on.
|
|
935
|
+
accent_width: The bar width in pixels.
|
|
936
|
+
accent_column: The column(s) the bar is drawn on; defaults to the leftmost rendered column.
|
|
937
|
+
dim_color: The text color of everything else; ``None`` emphasizes without dimming.
|
|
938
|
+
if_none: When ``rows`` matches nothing: ``"warn"`` (unchanged, with an SdvplotWarning), ``"dim"`` (dim the
|
|
939
|
+
whole table, for a spotlight that lives in another table of a grid) or ``"ignore"``.
|
|
940
|
+
|
|
941
|
+
Returns:
|
|
942
|
+
GT: A new table with the spotlight.
|
|
943
|
+
|
|
944
|
+
Raises:
|
|
945
|
+
TypeError: If ``gt`` is not a great_tables ``GT``.
|
|
946
|
+
ValueError: If ``if_none`` is not one of its choices.
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
Example:
|
|
951
|
+
::
|
|
952
|
+
|
|
953
|
+
import polars as pl
|
|
954
|
+
from great_tables import GT
|
|
955
|
+
from sdvplot.great_tables import gt_spotlight
|
|
956
|
+
|
|
957
|
+
df = pl.DataFrame({"team": ["LV", "KC", "BUF"], "wins": [10, 12, 11]})
|
|
958
|
+
gt = gt_spotlight(GT(df), pl.col("team") == "KC", accent_color="#E31837")
|
|
959
|
+
|
|
960
|
+
See Also:
|
|
961
|
+
Ported from sdvplotR ``gt_spotlight()``: https://sdvplotR.sportsdataverse.org/reference/gt_spotlight.html
|
|
962
|
+
"""
|
|
963
|
+
_check_gt(gt)
|
|
964
|
+
_choice("if_none", if_none, ("warn", "dim", "ignore"))
|
|
965
|
+
focus = _row_indices(gt, rows)
|
|
966
|
+
if not focus:
|
|
967
|
+
if if_none == "dim" and dim_color is not None:
|
|
968
|
+
return gt.tab_style(important(gst.text(color=dim_color)), loc.body())
|
|
969
|
+
if if_none == "warn":
|
|
970
|
+
msg = "rows matched no rows, so the table is unchanged; set if_none='dim' to dim the whole table instead"
|
|
971
|
+
warn(msg)
|
|
972
|
+
return gt
|
|
973
|
+
|
|
974
|
+
chosen = _columns(gt, columns)
|
|
975
|
+
rendered = _rendered(gt)
|
|
976
|
+
others = [i for i in range(len(_frame(gt))) if i not in set(focus)]
|
|
977
|
+
rest = [c for c in rendered if c not in chosen]
|
|
978
|
+
if dim_color is not None:
|
|
979
|
+
if others:
|
|
980
|
+
gt = gt.tab_style(important(gst.text(color=dim_color)), loc.body(rows=others))
|
|
981
|
+
if rest:
|
|
982
|
+
gt = gt.tab_style(important(gst.text(color=dim_color)), loc.body(columns=rest, rows=focus))
|
|
983
|
+
|
|
984
|
+
looks: list[Any] = [] if fill is None else [important(gst.fill(color=fill))]
|
|
985
|
+
if text_color is not None or bold:
|
|
986
|
+
looks.append(important(gst.text(color=text_color, weight="bold" if bold else None)))
|
|
987
|
+
if looks:
|
|
988
|
+
gt = gt.tab_style(looks, loc.body(columns=chosen, rows=focus))
|
|
989
|
+
|
|
990
|
+
if accent_color is not None:
|
|
991
|
+
edge = rendered[:1] if accent_column is None else [c for c in _columns(gt, accent_column) if c in rendered]
|
|
992
|
+
if not edge:
|
|
993
|
+
warn("accent_column matched no rendered column; no accent drawn")
|
|
994
|
+
else:
|
|
995
|
+
bar = important(gst.borders(sides="left", color=accent_color, weight=f"{accent_width:g}px"))
|
|
996
|
+
gt = gt.tab_style(bar, loc.body(columns=edge, rows=focus))
|
|
997
|
+
return gt
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def gt_row_accent(
|
|
1001
|
+
gt: GT,
|
|
1002
|
+
column: Any,
|
|
1003
|
+
*,
|
|
1004
|
+
palette: Mapping[str, str] | Sequence[str] | None = None,
|
|
1005
|
+
rows: Any = None,
|
|
1006
|
+
width: float = 4,
|
|
1007
|
+
side: str = "left",
|
|
1008
|
+
hide: bool = True,
|
|
1009
|
+
na_color: str = "transparent",
|
|
1010
|
+
) -> GT:
|
|
1011
|
+
"""Draw a colored bar on the edge of each row, keyed to a column (a team color, a conference).
|
|
1012
|
+
|
|
1013
|
+
The bar is a border on the stub, or on the leftmost rendered column when the table has no stub, so it lines up
|
|
1014
|
+
with the row.
|
|
1015
|
+
|
|
1016
|
+
Args:
|
|
1017
|
+
gt: The table.
|
|
1018
|
+
column: The one column the color is keyed to: a column of colors, or values mapped through ``palette``.
|
|
1019
|
+
palette: ``{value: color}``; or a list of colors assigned to the sorted distinct values and recycled.
|
|
1020
|
+
Defaults to reading ``column`` as colors.
|
|
1021
|
+
rows: The rows to accent (any great_tables row selection). Defaults to every row.
|
|
1022
|
+
width: The bar width in pixels.
|
|
1023
|
+
side: ``"left"`` or ``"right"``.
|
|
1024
|
+
hide: Hide ``column`` once the bars are drawn (what you want when it holds hex codes).
|
|
1025
|
+
na_color: The color for a missing or unmapped key; ``"transparent"`` draws no bar.
|
|
1026
|
+
|
|
1027
|
+
Returns:
|
|
1028
|
+
GT: A new table with the bars (unchanged, with an SdvplotWarning, when ``rows`` selects no rows).
|
|
1029
|
+
|
|
1030
|
+
Raises:
|
|
1031
|
+
TypeError: If ``gt`` is not a great_tables ``GT``.
|
|
1032
|
+
ValueError: If ``column`` does not select exactly one column, or ``side`` is not ``"left"``/``"right"``.
|
|
1033
|
+
|
|
1034
|
+
Example:
|
|
1035
|
+
::
|
|
1036
|
+
|
|
1037
|
+
import polars as pl
|
|
1038
|
+
from great_tables import GT
|
|
1039
|
+
from sdvplot.great_tables import gt_row_accent
|
|
1040
|
+
|
|
1041
|
+
df = pl.DataFrame({"team": ["Clemson", "Georgia"], "conf": ["ACC", "SEC"], "wins": [10, 12]})
|
|
1042
|
+
gt = gt_row_accent(GT(df), "conf", palette={"ACC": "#003366", "SEC": "#B8232F"})
|
|
1043
|
+
|
|
1044
|
+
See Also:
|
|
1045
|
+
Ported from sdvplotR ``gt_row_accent()``: https://sdvplotR.sportsdataverse.org/reference/gt_row_accent.html
|
|
1046
|
+
"""
|
|
1047
|
+
_check_gt(gt)
|
|
1048
|
+
_choice("side", side, ("left", "right"))
|
|
1049
|
+
selected = _columns(gt, column)
|
|
1050
|
+
if len(selected) != 1:
|
|
1051
|
+
raise ValueError(f"column must select exactly one column, got {selected}")
|
|
1052
|
+
keys = _strings(gt, selected[0])
|
|
1053
|
+
if palette is None:
|
|
1054
|
+
colors = list(keys)
|
|
1055
|
+
elif isinstance(palette, Mapping):
|
|
1056
|
+
colors = [None if k is None else palette.get(k) for k in keys]
|
|
1057
|
+
else:
|
|
1058
|
+
levels = sorted({k for k in keys if k is not None})
|
|
1059
|
+
lookup = {level: palette[i % len(palette)] for i, level in enumerate(levels)}
|
|
1060
|
+
colors = [None if k is None else lookup[k] for k in keys]
|
|
1061
|
+
fills = [na_color if c is None else c for c in colors]
|
|
1062
|
+
|
|
1063
|
+
keep = set(_row_indices(gt, rows))
|
|
1064
|
+
if not keep:
|
|
1065
|
+
warn("rows matched no rows; the table is unchanged")
|
|
1066
|
+
return gt
|
|
1067
|
+
if hide:
|
|
1068
|
+
gt = gt.cols_hide(columns=selected)
|
|
1069
|
+
has_stub = any(c.type.name == "stub" for c in gt._boxhead)
|
|
1070
|
+
rendered = _rendered(gt)
|
|
1071
|
+
if not rendered and not has_stub:
|
|
1072
|
+
return gt
|
|
1073
|
+
edge_side: Literal["left", "right"] = "left" if side == "left" else "right"
|
|
1074
|
+
for color in dict.fromkeys(fills):
|
|
1075
|
+
at = [i for i, c in enumerate(fills) if c == color and i in keep]
|
|
1076
|
+
if color == "transparent" or not at:
|
|
1077
|
+
continue
|
|
1078
|
+
where = loc.stub(rows=at) if has_stub else loc.body(columns=rendered[0], rows=at)
|
|
1079
|
+
gt = gt.tab_style(important(gst.borders(sides=edge_side, color=color, weight=f"{width:g}px")), where)
|
|
1080
|
+
return gt
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def _quantile(ordered: list[float], p: float) -> float:
|
|
1084
|
+
"""R's default (type 7) quantile of sorted values."""
|
|
1085
|
+
h = (len(ordered) - 1) * p
|
|
1086
|
+
i = math.floor(h)
|
|
1087
|
+
return ordered[i] if i + 1 >= len(ordered) else ordered[i] + (h - i) * (ordered[i + 1] - ordered[i])
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def _limits(method: str, values: list[float], threshold: float, bounds: Sequence[Any]) -> list[float]:
|
|
1091
|
+
"""The (low, high) outside which a value is an outlier; infinite when the spread is zero or undefined."""
|
|
1092
|
+
wide = [-math.inf, math.inf]
|
|
1093
|
+
if method == "bounds":
|
|
1094
|
+
lo, hi = (_number(b) for b in bounds)
|
|
1095
|
+
return [-math.inf if lo is None else lo, math.inf if hi is None else hi]
|
|
1096
|
+
if len(values) < 2:
|
|
1097
|
+
return wide
|
|
1098
|
+
if method == "sd":
|
|
1099
|
+
mean = sum(values) / len(values)
|
|
1100
|
+
sd = math.sqrt(sum((v - mean) ** 2 for v in values) / (len(values) - 1))
|
|
1101
|
+
return wide if sd == 0 else [mean - threshold * sd, mean + threshold * sd]
|
|
1102
|
+
ordered = sorted(values)
|
|
1103
|
+
q1, q3 = _quantile(ordered, 0.25), _quantile(ordered, 0.75)
|
|
1104
|
+
return wide if q3 == q1 else [q1 - threshold * (q3 - q1), q3 + threshold * (q3 - q1)]
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def gt_outliers(
|
|
1108
|
+
gt: GT,
|
|
1109
|
+
columns: Any,
|
|
1110
|
+
*,
|
|
1111
|
+
method: str = "iqr",
|
|
1112
|
+
threshold: float | None = None,
|
|
1113
|
+
bounds: Sequence[float | None] | None = None,
|
|
1114
|
+
side: str = "both",
|
|
1115
|
+
fill: str | None = None,
|
|
1116
|
+
color: str | None = None,
|
|
1117
|
+
bold: bool = True,
|
|
1118
|
+
symbol: str | None = None,
|
|
1119
|
+
note: bool | str | None = None,
|
|
1120
|
+
) -> GT:
|
|
1121
|
+
"""Flag outlying values in numeric columns: colored (and bold) text, an optional fill, symbol and source note.
|
|
1122
|
+
|
|
1123
|
+
Args:
|
|
1124
|
+
gt: The table.
|
|
1125
|
+
columns: The columns to test (any great_tables selection); non-numeric columns are skipped.
|
|
1126
|
+
method: ``"iqr"`` (beyond ``threshold`` x IQR of the quartiles, R's type-7 quantiles), ``"sd"`` (more than
|
|
1127
|
+
``threshold`` sample standard deviations from the mean) or ``"bounds"`` (outside ``bounds``).
|
|
1128
|
+
threshold: The cutoff for ``"iqr"`` (default 1.5) and ``"sd"`` (default 3).
|
|
1129
|
+
bounds: ``(lower, upper)`` for ``"bounds"``; ``None`` on either side leaves it open.
|
|
1130
|
+
side: ``"both"``, ``"high"`` or ``"low"``.
|
|
1131
|
+
fill: A hex fill behind flagged values (a translucent ``#rrggbbaa`` is drawn as given; the default text
|
|
1132
|
+
color is read on what it shows over the table background).
|
|
1133
|
+
color: The flagged text color; defaults to a warning red, or the readable ink when the red fails 4.5:1 on
|
|
1134
|
+
``fill``.
|
|
1135
|
+
bold: Bold flagged values.
|
|
1136
|
+
symbol: A marker appended to flagged values, such as ``"†"``.
|
|
1137
|
+
note: ``True`` adds a source note describing the rule; a string adds that note; ``None``/``False`` none.
|
|
1138
|
+
|
|
1139
|
+
Returns:
|
|
1140
|
+
GT: A new table (unchanged, with an SdvplotWarning, when no selected column is numeric).
|
|
1141
|
+
|
|
1142
|
+
Raises:
|
|
1143
|
+
TypeError: If ``gt`` is not a great_tables ``GT``.
|
|
1144
|
+
ValueError: If ``columns`` selects nothing, ``bounds`` is missing for ``"bounds"``, or an option is invalid.
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
Example:
|
|
1149
|
+
::
|
|
1150
|
+
|
|
1151
|
+
import polars as pl
|
|
1152
|
+
from great_tables import GT
|
|
1153
|
+
from sdvplot.great_tables import gt_outliers
|
|
1154
|
+
|
|
1155
|
+
df = pl.DataFrame({"team": list("ABCDEFG"), "pts": [21, 24, 20, 23, 22, 25, 61]})
|
|
1156
|
+
gt = gt_outliers(GT(df), "pts", symbol="†", note=True)
|
|
1157
|
+
|
|
1158
|
+
See Also:
|
|
1159
|
+
Ported from sdvplotR ``gt_outliers()``: https://sdvplotR.sportsdataverse.org/reference/gt_outliers.html
|
|
1160
|
+
"""
|
|
1161
|
+
_check_gt(gt)
|
|
1162
|
+
_choice("method", method, ("iqr", "sd", "bounds"))
|
|
1163
|
+
_choice("side", side, ("both", "high", "low"))
|
|
1164
|
+
if threshold is None:
|
|
1165
|
+
threshold = 3 if method == "sd" else 1.5
|
|
1166
|
+
if method == "bounds" and (bounds is None or len(bounds) != 2):
|
|
1167
|
+
raise ValueError("bounds must be (lower, upper) when method is 'bounds'")
|
|
1168
|
+
cols = _columns(gt, columns)
|
|
1169
|
+
if not cols:
|
|
1170
|
+
raise ValueError("columns matched no columns")
|
|
1171
|
+
data = _frame(gt)
|
|
1172
|
+
numeric = [c for c in cols if data[c].dtype.is_numeric()]
|
|
1173
|
+
if not numeric:
|
|
1174
|
+
warn("no numeric columns among columns; nothing to flag")
|
|
1175
|
+
return gt
|
|
1176
|
+
if color is None:
|
|
1177
|
+
shown = None if fill is None else solid(fill, _background(gt)) # fill goes to CSS as given
|
|
1178
|
+
color = "#B3261E" if shown is None or contrast("#B3261E", shown) >= 4.5 else on_color(shown)
|
|
1179
|
+
|
|
1180
|
+
flagged = False
|
|
1181
|
+
for col in numeric:
|
|
1182
|
+
values = _numbers(gt, col)
|
|
1183
|
+
low, high = _limits(method, [v for v in values if v is not None], threshold, bounds or ())
|
|
1184
|
+
hit = [
|
|
1185
|
+
i
|
|
1186
|
+
for i, v in enumerate(values)
|
|
1187
|
+
if v is not None and ((v < low and side != "high") or (v > high and side != "low"))
|
|
1188
|
+
]
|
|
1189
|
+
if not hit:
|
|
1190
|
+
continue
|
|
1191
|
+
flagged = True
|
|
1192
|
+
looks: list[Any] = [important(gst.text(color=color, weight="bold" if bold else None))]
|
|
1193
|
+
if fill is not None:
|
|
1194
|
+
looks.append(important(gst.fill(color=fill)))
|
|
1195
|
+
gt = gt.tab_style(looks, loc.body(columns=col, rows=hit))
|
|
1196
|
+
if symbol is not None:
|
|
1197
|
+
gt = gt.text_transform(loc.body(columns=col, rows=hit), functools.partial(_append, suffix=symbol))
|
|
1198
|
+
|
|
1199
|
+
if not flagged or note is None or note is False:
|
|
1200
|
+
return gt
|
|
1201
|
+
if note is True:
|
|
1202
|
+
tail = {"both": "", "high": " (high side only)", "low": " (low side only)"}[side]
|
|
1203
|
+
if method == "sd":
|
|
1204
|
+
plural = "" if threshold == 1 else "s"
|
|
1205
|
+
text = f"Marked values fall more than {threshold:g} standard deviation{plural} from the column mean{tail}."
|
|
1206
|
+
elif method == "iqr":
|
|
1207
|
+
text = f"Marked values fall outside {threshold:g} × IQR of the column quartiles{tail}."
|
|
1208
|
+
else:
|
|
1209
|
+
lo, hi = ("NA" if x is None else f"{x:g}" for x in map(_number, bounds or ()))
|
|
1210
|
+
text = f"Marked values fall outside {lo}–{hi}{tail}."
|
|
1211
|
+
else:
|
|
1212
|
+
text = str(note)
|
|
1213
|
+
return gt.tab_source_note(text)
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def gt_significance(
|
|
1217
|
+
gt: GT,
|
|
1218
|
+
columns: Any,
|
|
1219
|
+
p_columns: Any,
|
|
1220
|
+
*,
|
|
1221
|
+
levels: Sequence[float] = (0.01, 0.05, 0.1),
|
|
1222
|
+
symbols: Sequence[str] = ("***", "**", "*"),
|
|
1223
|
+
superscript: bool = True,
|
|
1224
|
+
size: str = "0.7em",
|
|
1225
|
+
legend: bool = True,
|
|
1226
|
+
legend_text: str | None = None,
|
|
1227
|
+
hide_p: bool = True,
|
|
1228
|
+
) -> GT:
|
|
1229
|
+
"""Append significance stars to estimates from paired p-value columns.
|
|
1230
|
+
|
|
1231
|
+
Each value takes the symbol of the strictest level its p-value is below (0.004 gets ``***``, not ``*``); values
|
|
1232
|
+
that meet no level, and missing p-values, are left alone. Stars follow the formatted text, so format first.
|
|
1233
|
+
|
|
1234
|
+
Args:
|
|
1235
|
+
gt: The table.
|
|
1236
|
+
columns: The estimate columns (any great_tables selection).
|
|
1237
|
+
p_columns: The p-value columns, paired with ``columns`` by position.
|
|
1238
|
+
levels: Significance thresholds, ascending (strictest first).
|
|
1239
|
+
symbols: The notation for each level.
|
|
1240
|
+
superscript: Render the stars as superscript.
|
|
1241
|
+
size: The stars' CSS font size.
|
|
1242
|
+
legend: Add a legend as a source note.
|
|
1243
|
+
legend_text: A custom legend; defaults to ``"*** p < 0.01, ** p < 0.05, * p < 0.1"`` from the levels.
|
|
1244
|
+
hide_p: Hide the p-value columns.
|
|
1245
|
+
|
|
1246
|
+
Returns:
|
|
1247
|
+
GT: A new table with the stars.
|
|
1248
|
+
|
|
1249
|
+
Raises:
|
|
1250
|
+
TypeError: If ``gt`` is not a great_tables ``GT``.
|
|
1251
|
+
ValueError: If ``levels`` and ``symbols`` differ in length, ``levels`` is not ascending, ``columns`` selects
|
|
1252
|
+
nothing, or the two selections do not pair up.
|
|
1253
|
+
|
|
1254
|
+
Example:
|
|
1255
|
+
::
|
|
1256
|
+
|
|
1257
|
+
import polars as pl
|
|
1258
|
+
from great_tables import GT
|
|
1259
|
+
from sdvplot.great_tables import gt_significance
|
|
1260
|
+
|
|
1261
|
+
df = pl.DataFrame({"term": ["epa", "wpa"], "est": [0.42, 0.08], "p": [0.004, 0.2]})
|
|
1262
|
+
gt = gt_significance(GT(df).fmt_number("est"), "est", "p")
|
|
1263
|
+
|
|
1264
|
+
See Also:
|
|
1265
|
+
Ported from sdvplotR ``gt_significance()``: https://sdvplotR.sportsdataverse.org/reference/gt_significance.html
|
|
1266
|
+
"""
|
|
1267
|
+
_check_gt(gt)
|
|
1268
|
+
if len(levels) != len(symbols):
|
|
1269
|
+
raise ValueError("levels and symbols must be the same length")
|
|
1270
|
+
if list(levels) != sorted(levels):
|
|
1271
|
+
raise ValueError("levels must be in ascending order, strictest first")
|
|
1272
|
+
estimates, p_cols = _columns(gt, columns), _columns(gt, p_columns)
|
|
1273
|
+
if not estimates:
|
|
1274
|
+
raise ValueError("columns matched no columns")
|
|
1275
|
+
if len(p_cols) != len(estimates):
|
|
1276
|
+
raise ValueError(
|
|
1277
|
+
f"p_columns must pair with columns: got {len(estimates)} estimate column(s) and {len(p_cols)} p-value "
|
|
1278
|
+
"column(s)"
|
|
1279
|
+
)
|
|
1280
|
+
for estimate, p_col in zip(estimates, p_cols, strict=True):
|
|
1281
|
+
marks = []
|
|
1282
|
+
for p in _numbers(gt, p_col):
|
|
1283
|
+
mark = next((s for level, s in zip(levels, symbols, strict=True) if p is not None and p < level), "")
|
|
1284
|
+
marks.append(f"<sup style='font-size:{size};'>{mark}</sup>" if superscript and mark else mark)
|
|
1285
|
+
for mark in dict.fromkeys(m for m in marks if m):
|
|
1286
|
+
rows = [i for i, m in enumerate(marks) if m == mark]
|
|
1287
|
+
gt = gt.text_transform(loc.body(columns=estimate, rows=rows), functools.partial(_append, suffix=mark))
|
|
1288
|
+
if hide_p:
|
|
1289
|
+
gt = gt.cols_hide(columns=p_cols)
|
|
1290
|
+
if legend:
|
|
1291
|
+
text = ", ".join(f"{s} p < {level:g}" for level, s in zip(levels, symbols, strict=True))
|
|
1292
|
+
gt = gt.tab_source_note(html(text if legend_text is None else legend_text))
|
|
1293
|
+
return gt
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
def gt_marginalia(
|
|
1297
|
+
gt: GT,
|
|
1298
|
+
columns: Any,
|
|
1299
|
+
*,
|
|
1300
|
+
width: float | str | None = 220,
|
|
1301
|
+
label: str | None = "",
|
|
1302
|
+
italic: bool = True,
|
|
1303
|
+
color: str | None = None,
|
|
1304
|
+
size: str = "0.92em",
|
|
1305
|
+
rule: bool = True,
|
|
1306
|
+
rule_color: str | None = None,
|
|
1307
|
+
align: str = "left",
|
|
1308
|
+
) -> GT:
|
|
1309
|
+
"""Turn columns into margin notes: muted italic prose in a fixed-width column behind a hairline rule.
|
|
1310
|
+
|
|
1311
|
+
Args:
|
|
1312
|
+
gt: The table.
|
|
1313
|
+
columns: The note columns (any great_tables selection).
|
|
1314
|
+
width: The column width (a number is pixels); the fixed width is what makes the prose wrap. ``None`` leaves
|
|
1315
|
+
it alone.
|
|
1316
|
+
label: The column label (empty by default); ``None`` keeps the existing label.
|
|
1317
|
+
italic: Italicize the notes.
|
|
1318
|
+
color: The text color; defaults to a muted ink that clears 4.5:1 on the table background (dark themes too).
|
|
1319
|
+
size: The CSS font size.
|
|
1320
|
+
rule: Draw a hairline on the left edge.
|
|
1321
|
+
rule_color: The hairline color; defaults to a faint tint of the ink.
|
|
1322
|
+
align: The text alignment.
|
|
1323
|
+
|
|
1324
|
+
Returns:
|
|
1325
|
+
GT: A new table with the note columns styled.
|
|
1326
|
+
|
|
1327
|
+
Raises:
|
|
1328
|
+
TypeError: If ``gt`` is not a great_tables ``GT``.
|
|
1329
|
+
ValueError: If ``columns`` selects nothing.
|
|
1330
|
+
|
|
1331
|
+
Example:
|
|
1332
|
+
::
|
|
1333
|
+
|
|
1334
|
+
import polars as pl
|
|
1335
|
+
from great_tables import GT
|
|
1336
|
+
from sdvplot.great_tables import gt_marginalia
|
|
1337
|
+
|
|
1338
|
+
df = pl.DataFrame({"team": ["LV"], "note": ["Lost the starting QB in week 3."]})
|
|
1339
|
+
gt = gt_marginalia(GT(df), "note")
|
|
1340
|
+
|
|
1341
|
+
See Also:
|
|
1342
|
+
Ported from sdvplotR ``gt_marginalia()``: https://sdvplotR.sportsdataverse.org/reference/gt_marginalia.html
|
|
1343
|
+
"""
|
|
1344
|
+
_check_gt(gt)
|
|
1345
|
+
cols = _columns(gt, columns)
|
|
1346
|
+
if not cols:
|
|
1347
|
+
raise ValueError("columns matched no columns")
|
|
1348
|
+
bg = _background(gt)
|
|
1349
|
+
ink = on_color(bg)
|
|
1350
|
+
color = _secondary_on(bg, ink) if color is None else color
|
|
1351
|
+
rule_color = mix(bg, ink, 0.18) if rule_color is None else rule_color
|
|
1352
|
+
out = gt.cols_align(align=align, columns=cols).tab_style(
|
|
1353
|
+
gst.text(color=color, size=size, style="italic" if italic else "normal"), loc.body(columns=cols)
|
|
1354
|
+
)
|
|
1355
|
+
if width is not None:
|
|
1356
|
+
out = out.cols_width(cases={c: _css_len(width) for c in cols})
|
|
1357
|
+
if label is not None:
|
|
1358
|
+
out = out.cols_label(cases={c: label for c in cols})
|
|
1359
|
+
if rule:
|
|
1360
|
+
out = out.tab_style(
|
|
1361
|
+
important(gst.borders(sides="left", color=rule_color, weight="1px")), loc.body(columns=cols)
|
|
1362
|
+
)
|
|
1363
|
+
return out
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
_SCALE_NAMES = {
|
|
1367
|
+
1e3: ("thousands", "(000s)"),
|
|
1368
|
+
1e6: ("millions", "(millions)"),
|
|
1369
|
+
1e9: ("billions", "(billions)"),
|
|
1370
|
+
1e12: ("trillions", "(trillions)"),
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
def gt_scale_note(
|
|
1375
|
+
gt: GT,
|
|
1376
|
+
columns: Any,
|
|
1377
|
+
*,
|
|
1378
|
+
divisor: float = 1000,
|
|
1379
|
+
note: str | None = None,
|
|
1380
|
+
where: str = "source_note",
|
|
1381
|
+
label_suffix: str | None = None,
|
|
1382
|
+
decimals: int = 0,
|
|
1383
|
+
**kwargs: Any,
|
|
1384
|
+
) -> GT:
|
|
1385
|
+
"""Divide columns by a round number and say so: "Figures in thousands." or a "(000s)" label suffix.
|
|
1386
|
+
|
|
1387
|
+
Args:
|
|
1388
|
+
gt: The table.
|
|
1389
|
+
columns: The columns to scale (any great_tables selection).
|
|
1390
|
+
divisor: The amount to divide by.
|
|
1391
|
+
note: The disclosure; defaults to "Figures in thousands." (millions, billions, trillions) or "Figures divided
|
|
1392
|
+
by 2,500." for other divisors.
|
|
1393
|
+
where: ``"source_note"``, ``"label"`` (append ``label_suffix`` to the column labels) or ``"both"``.
|
|
1394
|
+
label_suffix: The label suffix; defaults to "(000s)", "(millions)", ... or "(÷2,500)".
|
|
1395
|
+
decimals: Decimal places of the scaled values.
|
|
1396
|
+
**kwargs: Passed to great_tables ``fmt_number`` (``use_seps``, ``pattern``, ...).
|
|
1397
|
+
|
|
1398
|
+
Returns:
|
|
1399
|
+
GT: A new table with the columns formatted and the disclosure added.
|
|
1400
|
+
|
|
1401
|
+
Raises:
|
|
1402
|
+
TypeError: If ``gt`` is not a great_tables ``GT``.
|
|
1403
|
+
ValueError: If ``divisor`` is not a non-zero number, ``columns`` selects nothing, or ``where`` is invalid.
|
|
1404
|
+
|
|
1405
|
+
Example:
|
|
1406
|
+
::
|
|
1407
|
+
|
|
1408
|
+
import polars as pl
|
|
1409
|
+
from great_tables import GT
|
|
1410
|
+
from sdvplot.great_tables import gt_scale_note
|
|
1411
|
+
|
|
1412
|
+
df = pl.DataFrame({"team": ["LV", "KC"], "payroll": [254_000_000, 268_500_000]})
|
|
1413
|
+
gt = gt_scale_note(GT(df), "payroll", divisor=1e6, decimals=1, where="both")
|
|
1414
|
+
|
|
1415
|
+
See Also:
|
|
1416
|
+
Ported from sdvplotR ``gt_scale_note()``: https://sdvplotR.sportsdataverse.org/reference/gt_scale_note.html
|
|
1417
|
+
"""
|
|
1418
|
+
_check_gt(gt)
|
|
1419
|
+
_choice("where", where, ("source_note", "label", "both"))
|
|
1420
|
+
if isinstance(divisor, bool) or not isinstance(divisor, numbers.Real) or divisor == 0:
|
|
1421
|
+
raise ValueError(f"divisor must be a single non-zero number, got {divisor!r}")
|
|
1422
|
+
cols = _columns(gt, columns)
|
|
1423
|
+
if not cols:
|
|
1424
|
+
raise ValueError("columns matched no columns")
|
|
1425
|
+
named = _SCALE_NAMES.get(float(divisor))
|
|
1426
|
+
shown = f"{int(divisor):,}" if float(divisor).is_integer() else f"{divisor:,}"
|
|
1427
|
+
if note is None:
|
|
1428
|
+
note = f"Figures in {named[0]}." if named else f"Figures divided by {shown}."
|
|
1429
|
+
if label_suffix is None:
|
|
1430
|
+
label_suffix = named[1] if named else f"(÷{shown})"
|
|
1431
|
+
|
|
1432
|
+
out = gt.fmt_number(columns=cols, scale_by=1 / divisor, decimals=decimals, **kwargs)
|
|
1433
|
+
if where in ("source_note", "both"):
|
|
1434
|
+
out = out.tab_source_note(note)
|
|
1435
|
+
if where in ("label", "both"):
|
|
1436
|
+
current = {c.var: c.column_label for c in gt._boxhead}
|
|
1437
|
+
out = out.cols_label(cases={c: _suffixed(current[c], label_suffix) for c in cols})
|
|
1438
|
+
return out
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def _suffixed(label: Any, suffix: str) -> Any:
|
|
1442
|
+
"""A column label with ``suffix`` appended, keeping an HTML label HTML."""
|
|
1443
|
+
text = f"{getattr(label, 'text', label)} {suffix}"
|
|
1444
|
+
return html(text) if hasattr(label, "text") else text
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
# sdvplotR's friendly names -> Font Awesome icon names, tried in order. faicons (a great_tables dependency) 0.2.2
|
|
1448
|
+
# predates Font Awesome's x-twitter, bluesky, threads and substack icons, so X falls back to the Twitter bird.
|
|
1449
|
+
_SOCIAL = {
|
|
1450
|
+
"x": ("x-twitter", "twitter"),
|
|
1451
|
+
"twitter": ("x-twitter", "twitter"),
|
|
1452
|
+
"ig": ("instagram",),
|
|
1453
|
+
"bsky": ("bluesky",),
|
|
1454
|
+
"gh": ("github",),
|
|
1455
|
+
"yt": ("youtube",),
|
|
1456
|
+
"fb": ("facebook",),
|
|
1457
|
+
"web": ("globe",),
|
|
1458
|
+
"website": ("globe",),
|
|
1459
|
+
"link": ("globe",),
|
|
1460
|
+
"email": ("envelope",),
|
|
1461
|
+
"mail": ("envelope",),
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
def _social_icon(key: str, fill: str, height: str) -> str:
|
|
1466
|
+
"""The Font Awesome SVG for a platform name or alias, or a ValueError naming the installed faicons version."""
|
|
1467
|
+
names = _SOCIAL.get(key.lower(), (key.lower(),))
|
|
1468
|
+
for name in names:
|
|
1469
|
+
try:
|
|
1470
|
+
return str(faicons.icon_svg(name, fill=fill, height=height))
|
|
1471
|
+
except ValueError:
|
|
1472
|
+
continue
|
|
1473
|
+
version = importlib.metadata.version("faicons")
|
|
1474
|
+
raise ValueError(
|
|
1475
|
+
f"icon {names[0]!r} was not found in your installed faicons ({version}); update faicons or use a "
|
|
1476
|
+
"different icon or alias"
|
|
1477
|
+
)
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
def gt_social_tag(
|
|
1481
|
+
gt: GT,
|
|
1482
|
+
accounts: Mapping[str, str],
|
|
1483
|
+
*,
|
|
1484
|
+
caption: str | None = None,
|
|
1485
|
+
stack: bool = False,
|
|
1486
|
+
separator: str = " | ",
|
|
1487
|
+
align: str = "right",
|
|
1488
|
+
icon_color: str | None = None,
|
|
1489
|
+
icon_height: str = "0.9em",
|
|
1490
|
+
text_size: str | None = None,
|
|
1491
|
+
text_weight: str | int | None = None,
|
|
1492
|
+
**kwargs: Any,
|
|
1493
|
+
) -> GT:
|
|
1494
|
+
"""Sign a table with social handles, each behind its platform's icon, under an optional caption.
|
|
1495
|
+
|
|
1496
|
+
Args:
|
|
1497
|
+
gt: The table.
|
|
1498
|
+
accounts: ``{platform: handle}``. Platforms are Font Awesome brand icon names or the aliases ``x``/``twitter``,
|
|
1499
|
+
``ig``, ``bsky``, ``gh``, ``yt``, ``fb``, ``web``/``website``/``link`` and ``email``/``mail``.
|
|
1500
|
+
caption: A caption line above the handles, drawn by ``gt_538_caption``.
|
|
1501
|
+
stack: One account per line instead of a row.
|
|
1502
|
+
separator: The string between accounts in a row.
|
|
1503
|
+
align: The handle line's alignment: ``"left"``, ``"center"`` or ``"right"``.
|
|
1504
|
+
icon_color: The icons' color; defaults to the text color.
|
|
1505
|
+
icon_height: The icons' CSS height (``em`` scales with ``text_size``).
|
|
1506
|
+
text_size: The handles' CSS font size; defaults to the source-note size.
|
|
1507
|
+
text_weight: The handles' font weight.
|
|
1508
|
+
**kwargs: Passed to ``gt_538_caption`` (``rule_color``, ``rule_width``, ``size``) when ``caption`` is given.
|
|
1509
|
+
|
|
1510
|
+
Returns:
|
|
1511
|
+
GT: A new table with the handle line (and caption) as source notes.
|
|
1512
|
+
|
|
1513
|
+
Raises:
|
|
1514
|
+
TypeError: If ``gt`` is not a great_tables ``GT``.
|
|
1515
|
+
ValueError: If ``accounts`` is not a non-empty mapping of platform to handle, an icon is not in the
|
|
1516
|
+
installed faicons, or ``align`` is not ``"left"``, ``"center"`` or ``"right"``.
|
|
1517
|
+
|
|
1518
|
+
Example:
|
|
1519
|
+
::
|
|
1520
|
+
|
|
1521
|
+
import polars as pl
|
|
1522
|
+
from great_tables import GT
|
|
1523
|
+
from sdvplot.great_tables import gt_social_tag
|
|
1524
|
+
|
|
1525
|
+
gt = gt_social_tag(GT(pl.DataFrame({"team": ["LV"]})), {"gh": "sportsdataverse", "web": "sdv.org"})
|
|
1526
|
+
|
|
1527
|
+
See Also:
|
|
1528
|
+
Ported from sdvplotR ``gt_social_tag()``: https://sdvplotR.sportsdataverse.org/reference/gt_social_tag.html
|
|
1529
|
+
"""
|
|
1530
|
+
_check_gt(gt)
|
|
1531
|
+
_choice("align", align, ("left", "center", "right"))
|
|
1532
|
+
if not isinstance(accounts, Mapping) or not accounts or not all(isinstance(k, str) and k for k in accounts):
|
|
1533
|
+
raise ValueError("accounts must be a mapping of platform to handle, such as {'x': '@you', 'gh': 'you'}")
|
|
1534
|
+
fill = "currentColor" if icon_color is None else icon_color
|
|
1535
|
+
items = [
|
|
1536
|
+
"<span style='display:inline-flex; align-items:center; gap:0.3em; white-space:nowrap;'>"
|
|
1537
|
+
f"{_social_icon(key, fill, icon_height)}{handle}</span>"
|
|
1538
|
+
for key, handle in accounts.items()
|
|
1539
|
+
]
|
|
1540
|
+
joined = "<br>".join(items) if stack else separator.join(items)
|
|
1541
|
+
box = f"text-align:{align};"
|
|
1542
|
+
if text_size is not None:
|
|
1543
|
+
box += f" font-size:{text_size};"
|
|
1544
|
+
if text_weight is not None:
|
|
1545
|
+
box += f" font-weight:{text_weight};"
|
|
1546
|
+
social = f"<div style='{box}'>{joined}</div>"
|
|
1547
|
+
if caption is not None:
|
|
1548
|
+
from sdvplot.great_tables._cells import gt_538_caption # wave C1
|
|
1549
|
+
|
|
1550
|
+
return gt_538_caption(gt, top_caption=caption, bottom_caption=social, **kwargs)
|
|
1551
|
+
return gt.tab_source_note(html(social))
|
|
1552
|
+
|
|
1553
|
+
|
|
1554
|
+
def _snake_shape(n: int, n_cols: int, rows_per_col: int | None) -> tuple[int, int]:
|
|
1555
|
+
"""(blocks, rows per block) for ``n`` rows, from ``n_cols`` or, when given, ``rows_per_col``."""
|
|
1556
|
+
if rows_per_col is not None:
|
|
1557
|
+
per = int(rows_per_col)
|
|
1558
|
+
if per < 1:
|
|
1559
|
+
raise ValueError("rows_per_col must be at least 1")
|
|
1560
|
+
return math.ceil(n / per), per
|
|
1561
|
+
blocks = int(n_cols)
|
|
1562
|
+
if blocks < 1:
|
|
1563
|
+
raise ValueError("n_cols must be at least 1")
|
|
1564
|
+
return blocks, math.ceil(n / blocks)
|
|
1565
|
+
|
|
1566
|
+
|
|
1567
|
+
def _block(data: Any, cols: list[str], i: int, per: int, fill: Any) -> Any:
|
|
1568
|
+
"""Rows ``i*per`` to ``(i+1)*per`` of ``cols`` (a pandas or polars frame), padded with ``fill`` to ``per`` rows
|
|
1569
|
+
and suffixed ``_{i+1}``."""
|
|
1570
|
+
names = {c: f"{c}_{i + 1}" for c in cols}
|
|
1571
|
+
if isinstance(data, pl.DataFrame):
|
|
1572
|
+
part = data.select(cols).slice(i * per, per)
|
|
1573
|
+
short = per - part.height
|
|
1574
|
+
if short:
|
|
1575
|
+
pad = pl.DataFrame({c: [fill] * short for c in cols})
|
|
1576
|
+
part = pl.concat([part, pad], how="vertical_relaxed")
|
|
1577
|
+
return part.rename(names)
|
|
1578
|
+
part = data[cols].iloc[i * per : (i + 1) * per].reset_index(drop=True)
|
|
1579
|
+
short = per - len(part)
|
|
1580
|
+
if short:
|
|
1581
|
+
# object dtype keeps integers as integers next to the padding (a float column would print 3 as 3.0)
|
|
1582
|
+
part = part.astype(object).reindex(range(per))
|
|
1583
|
+
part.iloc[per - short :] = fill
|
|
1584
|
+
return part.rename(columns=names)
|
|
1585
|
+
|
|
1586
|
+
|
|
1587
|
+
def _side_by_side(parts: list[Any]) -> Any:
|
|
1588
|
+
return nw.concat([nw.from_native(p, eager_only=True) for p in parts], how="horizontal").to_native()
|
|
1589
|
+
|
|
1590
|
+
|
|
1591
|
+
def gt_snake(
|
|
1592
|
+
gt: GT,
|
|
1593
|
+
*,
|
|
1594
|
+
n_cols: int = 2,
|
|
1595
|
+
rows_per_col: int | None = None,
|
|
1596
|
+
gap: float = 20,
|
|
1597
|
+
fill: str | None = "",
|
|
1598
|
+
clean_gaps: bool = True,
|
|
1599
|
+
) -> GT:
|
|
1600
|
+
"""Wrap a long table into side-by-side blocks (a top-50 list as two columns of 25).
|
|
1601
|
+
|
|
1602
|
+
The table is rebuilt from its data: each visible column appears once per block, suffixed ``_1``, ``_2``, ...
|
|
1603
|
+
(``gt_snake_align`` reshapes helper data the same way). Labels, the header, source notes and body-cell styles
|
|
1604
|
+
(``tab_style`` on ``loc.body``, moved to their block's column and row) carry over; formats, text transforms,
|
|
1605
|
+
options and themes do not, so apply those after snaking. The recorded legend scale is dropped too.
|
|
1606
|
+
|
|
1607
|
+
Args:
|
|
1608
|
+
gt: The table, from pandas or polars data.
|
|
1609
|
+
n_cols: The number of blocks.
|
|
1610
|
+
rows_per_col: Rows per block; given this, the number of blocks follows from the data.
|
|
1611
|
+
gap: Pixels of empty spacer column between blocks; 0 for none.
|
|
1612
|
+
fill: What the padding cells of the last block show; ``None`` leaves them missing.
|
|
1613
|
+
clean_gaps: Scrub borders, fills and rules off the spacer columns so the gap stays clean under any theme
|
|
1614
|
+
(set ``False`` when you style the gap yourself).
|
|
1615
|
+
|
|
1616
|
+
Returns:
|
|
1617
|
+
GT: A new, snaked table (``gt`` unchanged when there are fewer than two blocks or no rows).
|
|
1618
|
+
|
|
1619
|
+
Raises:
|
|
1620
|
+
TypeError: If ``gt`` is not a great_tables ``GT``.
|
|
1621
|
+
ValueError: If ``n_cols`` or ``rows_per_col`` is below 1.
|
|
1622
|
+
|
|
1623
|
+
Example:
|
|
1624
|
+
::
|
|
1625
|
+
|
|
1626
|
+
import polars as pl
|
|
1627
|
+
from great_tables import GT
|
|
1628
|
+
from sdvplot.great_tables import gt_snake
|
|
1629
|
+
|
|
1630
|
+
df = pl.DataFrame({"rank": range(1, 51), "team": [f"T{i}" for i in range(1, 51)]})
|
|
1631
|
+
gt = gt_snake(GT(df), n_cols=2).fmt_integer(["rank_1", "rank_2"])
|
|
1632
|
+
|
|
1633
|
+
See Also:
|
|
1634
|
+
Ported from sdvplotR ``gt_snake()``: https://sdvplotR.sportsdataverse.org/reference/gt_snake.html
|
|
1635
|
+
"""
|
|
1636
|
+
_check_gt(gt)
|
|
1637
|
+
data = gt._tbl_data
|
|
1638
|
+
n = len(_frame(gt))
|
|
1639
|
+
blocks, per = _snake_shape(n, n_cols, rows_per_col)
|
|
1640
|
+
if blocks < 2 or n == 0:
|
|
1641
|
+
return gt
|
|
1642
|
+
|
|
1643
|
+
cols = [c.var for c in gt._boxhead if c.type.name in ("default", "stub")]
|
|
1644
|
+
labels: dict[str, Any] = {c.var: c.var if c.column_label is None else c.column_label for c in gt._boxhead}
|
|
1645
|
+
parts: list[Any] = []
|
|
1646
|
+
spacers: list[str] = []
|
|
1647
|
+
for i in range(blocks):
|
|
1648
|
+
parts.append(_block(data, cols, i, per, None))
|
|
1649
|
+
if gap > 0 and i < blocks - 1:
|
|
1650
|
+
spacers.append(f".gap{i + 1}")
|
|
1651
|
+
parts.append(type(data)({spacers[-1]: [""] * per}))
|
|
1652
|
+
out = _side_by_side(parts)
|
|
1653
|
+
|
|
1654
|
+
table_id = gt._options.table_id.value
|
|
1655
|
+
do_clean = clean_gaps and bool(spacers)
|
|
1656
|
+
if table_id is None and do_clean:
|
|
1657
|
+
table_id = "".join(random.choices(string.ascii_lowercase, k=10))
|
|
1658
|
+
res = GT(out, id=table_id)
|
|
1659
|
+
res = res.cols_label(
|
|
1660
|
+
cases={f"{c}_{i + 1}": labels[c] for i in range(blocks) for c in cols} | {s: "" for s in spacers}
|
|
1661
|
+
)
|
|
1662
|
+
padded = blocks * per - n
|
|
1663
|
+
if padded and fill is not None:
|
|
1664
|
+
last = [f"{c}_{blocks}" for c in cols]
|
|
1665
|
+
res = res.sub_missing(columns=last, rows=list(range(per - padded, per)), missing_text=fill)
|
|
1666
|
+
if spacers:
|
|
1667
|
+
res = res.cols_width(cases={s: f"{gap:g}px" for s in spacers})
|
|
1668
|
+
|
|
1669
|
+
# body-cell styles move to their block's column and row; other column styles repeat in every block
|
|
1670
|
+
moved = []
|
|
1671
|
+
for s in gt._styles:
|
|
1672
|
+
if s.colname is not None and s.colname not in cols:
|
|
1673
|
+
continue
|
|
1674
|
+
if type(s.locname).__name__ == "LocBody" and s.rownum is not None:
|
|
1675
|
+
block, row = divmod(s.rownum, per)
|
|
1676
|
+
moved.append(dataclasses.replace(s, colname=f"{s.colname}_{block + 1}", rownum=row))
|
|
1677
|
+
elif s.colname is not None:
|
|
1678
|
+
moved.extend(dataclasses.replace(s, colname=f"{s.colname}_{i + 1}") for i in range(blocks))
|
|
1679
|
+
else:
|
|
1680
|
+
moved.append(s)
|
|
1681
|
+
# what belongs to the table rather than its cells carries over as is
|
|
1682
|
+
res = res._replace(_heading=gt._heading, _source_notes=gt._source_notes, _styles=[*res._styles, *moved])
|
|
1683
|
+
|
|
1684
|
+
if do_clean:
|
|
1685
|
+
names = nw.from_native(out, eager_only=True).columns
|
|
1686
|
+
css = []
|
|
1687
|
+
for k in (names.index(s) + 1 for s in spacers):
|
|
1688
|
+
cell = f"#{table_id} td:nth-child"
|
|
1689
|
+
css += [
|
|
1690
|
+
f"{cell}({k}) {{border: 1px solid transparent !important; background: transparent !important;"
|
|
1691
|
+
" box-shadow: none !important;}",
|
|
1692
|
+
f"{cell}({k - 1}) {{border-right: 1px solid transparent !important;}}",
|
|
1693
|
+
f"{cell}({k + 1}) {{border-left: 1px solid transparent !important;}}",
|
|
1694
|
+
]
|
|
1695
|
+
res = res.opt_css("\n".join(css)).tab_style(
|
|
1696
|
+
important(gst.borders(sides="all", color="transparent", weight="1px")), loc.column_labels(columns=spacers)
|
|
1697
|
+
)
|
|
1698
|
+
return res
|
|
1699
|
+
|
|
1700
|
+
|
|
1701
|
+
def gt_snake_align(x: Any, n_cols: int = 2, rows_per_col: int | None = None, fill: Any = None) -> Any:
|
|
1702
|
+
"""Reshape a frame the way ``gt_snake`` reshapes a table, so helper data (highlight masks, colors) lines up.
|
|
1703
|
+
|
|
1704
|
+
Args:
|
|
1705
|
+
x: A pandas or polars frame with one row per row of the un-snaked table.
|
|
1706
|
+
n_cols: The number of blocks, as passed to ``gt_snake``.
|
|
1707
|
+
rows_per_col: Rows per block, as passed to ``gt_snake`` (then ``n_cols`` follows from the data).
|
|
1708
|
+
fill: The value of the trailing cells when the rows do not divide evenly (missing by default).
|
|
1709
|
+
|
|
1710
|
+
Returns:
|
|
1711
|
+
The same kind of frame, with each column once per block, suffixed ``_1``, ``_2``, ... (``x`` unchanged when
|
|
1712
|
+
there are fewer than two blocks or no rows).
|
|
1713
|
+
|
|
1714
|
+
Raises:
|
|
1715
|
+
TypeError: If ``x`` is not a pandas or polars frame.
|
|
1716
|
+
ValueError: If ``n_cols`` or ``rows_per_col`` is below 1.
|
|
1717
|
+
|
|
1718
|
+
Example:
|
|
1719
|
+
::
|
|
1720
|
+
|
|
1721
|
+
import polars as pl
|
|
1722
|
+
from sdvplot.great_tables import gt_snake_align
|
|
1723
|
+
|
|
1724
|
+
wide = gt_snake_align(pl.DataFrame({"hot": [True, False, True]}), n_cols=2) # hot_1, hot_2
|
|
1725
|
+
|
|
1726
|
+
See Also:
|
|
1727
|
+
Ported from sdvplotR ``gt_snake_align()``: https://sdvplotR.sportsdataverse.org/reference/gt_snake_align.html
|
|
1728
|
+
"""
|
|
1729
|
+
frame = nw.from_native(x, eager_only=True)
|
|
1730
|
+
n = len(frame)
|
|
1731
|
+
blocks, per = _snake_shape(n, n_cols, rows_per_col)
|
|
1732
|
+
if blocks < 2 or n == 0:
|
|
1733
|
+
return x
|
|
1734
|
+
return _side_by_side([_block(x, frame.columns, i, per, fill) for i in range(blocks)])
|
|
1735
|
+
|
|
1736
|
+
|
|
1737
|
+
def _strwrap(text: str, width: int) -> list[str]:
|
|
1738
|
+
"""R's ``strwrap``: greedy lines shorter than ``width``, split on whitespace only."""
|
|
1739
|
+
return textwrap.wrap(text, max(width - 1, 1), break_long_words=False, break_on_hyphens=False)
|
|
1740
|
+
|
|
1741
|
+
|
|
1742
|
+
def _balanced(words: list[str], width: int) -> list[str]:
|
|
1743
|
+
"""As many lines as ``strwrap`` needs, filled toward the mean length instead of the maximum."""
|
|
1744
|
+
greedy = _strwrap(" ".join(words), width)
|
|
1745
|
+
if len(greedy) <= 1:
|
|
1746
|
+
return [" ".join(words)]
|
|
1747
|
+
target = math.ceil(sum(len(w) + 1 for w in words) / len(greedy))
|
|
1748
|
+
lines: list[str] = []
|
|
1749
|
+
current = ""
|
|
1750
|
+
for word in words:
|
|
1751
|
+
candidate = f"{current} {word}" if current else word
|
|
1752
|
+
if len(candidate) > target and current:
|
|
1753
|
+
lines.append(current)
|
|
1754
|
+
current = word
|
|
1755
|
+
else:
|
|
1756
|
+
current = candidate
|
|
1757
|
+
return [*lines, current] if current else lines
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
def gt_wrap_labels(gt: GT, columns: Any = None, width: int = 12, balance: bool = True) -> GT:
|
|
1761
|
+
"""Wrap long column labels onto several lines, so narrow columns keep readable headers.
|
|
1762
|
+
|
|
1763
|
+
A one-word label, or one already shorter than ``width``, is left alone; a single long word is never split.
|
|
1764
|
+
|
|
1765
|
+
Args:
|
|
1766
|
+
gt: The table.
|
|
1767
|
+
columns: The columns whose labels wrap (any great_tables selection); defaults to every column.
|
|
1768
|
+
width: The target line length in characters (lines stay shorter than this, as R's ``strwrap``).
|
|
1769
|
+
balance: Even the lines out instead of filling them greedily.
|
|
1770
|
+
|
|
1771
|
+
Returns:
|
|
1772
|
+
GT: A new table with the wrapped labels.
|
|
1773
|
+
|
|
1774
|
+
Raises:
|
|
1775
|
+
TypeError: If ``gt`` is not a great_tables ``GT``.
|
|
1776
|
+
ValueError: If ``columns`` names a column the table lacks.
|
|
1777
|
+
|
|
1778
|
+
Example:
|
|
1779
|
+
::
|
|
1780
|
+
|
|
1781
|
+
import polars as pl
|
|
1782
|
+
from great_tables import GT
|
|
1783
|
+
from sdvplot.great_tables import gt_wrap_labels
|
|
1784
|
+
|
|
1785
|
+
gt = gt_wrap_labels(GT(pl.DataFrame({"Expected points added per play": [0.12]})))
|
|
1786
|
+
|
|
1787
|
+
See Also:
|
|
1788
|
+
Ported from sdvplotR ``gt_wrap_labels()``: https://sdvplotR.sportsdataverse.org/reference/gt_wrap_labels.html
|
|
1789
|
+
"""
|
|
1790
|
+
_check_gt(gt)
|
|
1791
|
+
labels = {c.var: c.column_label for c in gt._boxhead}
|
|
1792
|
+
wrapped: dict[str, Any] = {}
|
|
1793
|
+
for col in _columns(gt, columns):
|
|
1794
|
+
label = labels.get(col)
|
|
1795
|
+
text = col if label is None else str(getattr(label, "text", label))
|
|
1796
|
+
words = text.split()
|
|
1797
|
+
if len(words) <= 1:
|
|
1798
|
+
continue
|
|
1799
|
+
lines = _balanced(words, width) if balance else _strwrap(text, width)
|
|
1800
|
+
if len(lines) > 1:
|
|
1801
|
+
wrapped[col] = html("<br>".join(lines))
|
|
1802
|
+
return gt.cols_label(cases=wrapped) if wrapped else gt
|