sdvplot 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. sdvplot/__init__.py +68 -0
  2. sdvplot/_cache.py +484 -0
  3. sdvplot/_colors.py +206 -0
  4. sdvplot/_contrast.py +60 -0
  5. sdvplot/_court.py +110 -0
  6. sdvplot/_deprecate.py +39 -0
  7. sdvplot/_dispatch.py +342 -0
  8. sdvplot/_errors.py +93 -0
  9. sdvplot/_headshots.py +131 -0
  10. sdvplot/_images.py +292 -0
  11. sdvplot/_index.py +178 -0
  12. sdvplot/_lazy.py +15 -0
  13. sdvplot/_manifest.py +73 -0
  14. sdvplot/_marks.py +296 -0
  15. sdvplot/_normalize.py +82 -0
  16. sdvplot/_placement.py +168 -0
  17. sdvplot/_resolve.py +363 -0
  18. sdvplot/_surface.py +220 -0
  19. sdvplot/_tables.py +87 -0
  20. sdvplot/_tiers.py +135 -0
  21. sdvplot/_types.py +38 -0
  22. sdvplot/_versions.py +30 -0
  23. sdvplot/_web.py +61 -0
  24. sdvplot/altair.py +652 -0
  25. sdvplot/bokeh.py +323 -0
  26. sdvplot/data/INDEX_VERSION +1 -0
  27. sdvplot/data/aliases.parquet +0 -0
  28. sdvplot/data/teams.parquet +0 -0
  29. sdvplot/folium.py +316 -0
  30. sdvplot/great_tables/__init__.py +267 -0
  31. sdvplot/great_tables/_cells.py +1814 -0
  32. sdvplot/great_tables/_export.py +844 -0
  33. sdvplot/great_tables/_layout.py +1802 -0
  34. sdvplot/great_tables/_marks.py +798 -0
  35. sdvplot/great_tables/_themes.py +2134 -0
  36. sdvplot/holoviews.py +303 -0
  37. sdvplot/matplotlib.py +934 -0
  38. sdvplot/plotly.py +595 -0
  39. sdvplot/plotnine.py +1104 -0
  40. sdvplot/plottable.py +141 -0
  41. sdvplot/py.typed +0 -0
  42. sdvplot/pygal.py +416 -0
  43. sdvplot/reactable.py +488 -0
  44. sdvplot/testing.py +579 -0
  45. sdvplot/typing.py +21 -0
  46. sdvplot-0.1.0.dist-info/METADATA +188 -0
  47. sdvplot-0.1.0.dist-info/RECORD +49 -0
  48. sdvplot-0.1.0.dist-info/WHEEL +4 -0
  49. sdvplot-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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("&", "&amp;").replace("<", "&lt;").replace('"', "&quot;") 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