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,1814 @@
1
+ """sdvplotR's cell styling and formatting helpers for great_tables (wave C1).
2
+
3
+ Each function ports the sdvplotR function of the same name. Rows are great_tables row selections: 0-based
4
+ positions, a polars expression (polars data) or a callable that takes the data and returns a boolean Series
5
+ (pandas data). Columns are anything great_tables accepts as a column selection. Divergences from R are listed in
6
+ ``docs/PARITY_TABLES.md``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import copy
12
+ import inspect
13
+ import math
14
+ import re
15
+ from collections.abc import Callable, Sequence
16
+ from decimal import Decimal
17
+ from html import escape
18
+ from numbers import Real
19
+ from typing import Any, cast
20
+ from urllib.parse import quote
21
+
22
+ import narwhals as nw
23
+ from great_tables import GT, google_font, html, loc, md, style, vals
24
+ from great_tables._gt_data import Body, Boxhead, ColInfo
25
+ from great_tables._locations import resolve_cols_c, resolve_rows_i
26
+ from great_tables._text import _process_text
27
+
28
+ from sdvplot._contrast import hex6, mix, on_color, solid
29
+ from sdvplot._errors import warn
30
+ from sdvplot._tables import row_positions
31
+ from sdvplot.great_tables._marks import (
32
+ _background,
33
+ _check_columns,
34
+ _check_gt,
35
+ _constant,
36
+ _table_id,
37
+ _warn_striped,
38
+ important,
39
+ )
40
+
41
+ # ---------------------------------------------------------------------------------------------------------------------
42
+ # shared helpers (sdvplotR R/utils-theme.R and the per-function row/column handling)
43
+
44
+
45
+ def _warn(message: str) -> None:
46
+ warn(message)
47
+
48
+
49
+ def _one_of(name: str, value: Any, allowed: tuple[str, ...]) -> None:
50
+ """Raise unless ``value`` is one of ``allowed`` (the strings it is interpolated into CSS as)."""
51
+ if value not in allowed:
52
+ raise ValueError(f"{name} must be one of {allowed}, got {value!r}")
53
+
54
+
55
+ def _frame(gt: GT) -> Any:
56
+ """The GT's data as a narwhals DataFrame (pandas or polars); positions, never index labels."""
57
+ return nw.from_native(gt._tbl_data, eager_only=True)
58
+
59
+
60
+ def _columns(gt: GT, columns: Any) -> list[str]:
61
+ """The body columns a great_tables column selection names (``None`` means every body column). A name the table
62
+ lacks is the same ValueError on pandas and polars (great_tables matched nothing on pandas, raised on polars)."""
63
+ _check_columns(gt, columns)
64
+ return resolve_cols_c(data=gt, expr=columns)
65
+
66
+
67
+ def _values(gt: GT, column: str) -> list[Any]:
68
+ return list(_frame(gt)[column].to_list())
69
+
70
+
71
+ def _row_indices(gt: GT, rows: Any) -> list[int]:
72
+ """0-based row positions for a great_tables row selection (``None`` means every row; numpy integers count)."""
73
+ return [i for _, i in resolve_rows_i(gt, row_positions(rows))]
74
+
75
+
76
+ def _kept_rows(gt: GT, rows: Any) -> list[int] | None:
77
+ """Rows to style, or ``None`` (after one warning) when ``rows`` was given and matched nothing."""
78
+ idx = _row_indices(gt, rows)
79
+ if rows is not None and not idx:
80
+ _warn("rows matched no rows; returning the table unchanged")
81
+ return None
82
+ return idx
83
+
84
+
85
+ def _is_na(v: Any) -> bool:
86
+ """R's ``is.na``: None, NaN, NaT and pandas.NA."""
87
+ if v is None:
88
+ return True
89
+ try:
90
+ return bool(v != v)
91
+ except TypeError: # pandas.NA refuses bool()
92
+ return True
93
+
94
+
95
+ def _flags(values: Any) -> list[bool]:
96
+ """A boolean vector with R's ``!is.na(x) & as.logical(x)``: missing counts as not matched."""
97
+ out = []
98
+ for v in list(values):
99
+ try:
100
+ out.append(not _is_na(v) and bool(v))
101
+ except (TypeError, ValueError):
102
+ out.append(False)
103
+ return out
104
+
105
+
106
+ # ---------------------------------------------------------------------------------------------------------------------
107
+ # rows and cells
108
+
109
+
110
+ def gt_bold_rows(gt: GT, rows: Any = None, text_color: str = "black", highlight_color: str | None = None) -> GT:
111
+ """Bold the body cells of chosen rows, optionally recoloring their text and filling them.
112
+
113
+ Args:
114
+ gt: The table.
115
+ rows: The rows to bold, as great_tables' ``loc.body(rows=)`` takes them: a 0-based position or list of
116
+ positions, a polars expression (polars data) or a callable returning a boolean Series (pandas data).
117
+ ``None`` bolds every row.
118
+ text_color: Text color of the bolded rows.
119
+ highlight_color: Background fill of the bolded rows; ``None`` for no fill.
120
+
121
+ Returns:
122
+ GT: A new table; ``gt`` is unchanged. When ``rows`` matches nothing, ``gt`` itself, with one
123
+ SdvplotWarning.
124
+
125
+ Raises:
126
+ TypeError: ``gt`` is not a ``GT``.
127
+
128
+ Example:
129
+ ::
130
+
131
+ import polars as pl
132
+ from great_tables import GT
133
+ from sdvplot.great_tables import gt_bold_rows
134
+
135
+ df = pl.DataFrame(
136
+ {"car": ["Mazda", "Datsun", "Hornet"], "mpg": [21.0, 22.8, 18.7], "hp": [110, 93, 175],
137
+ "disp": [160.0, 108.0, 360.0]}
138
+ )
139
+
140
+ gt_bold_rows(GT(df), rows=pl.col("mpg") > 20, highlight_color="#FFF3B0")
141
+
142
+ See Also:
143
+ Ported from sdvplotR ``gt_bold_rows()``.
144
+ """
145
+ _check_gt(gt)
146
+ idx = _kept_rows(gt, rows)
147
+ if idx is None:
148
+ return gt
149
+ styles: list[Any] = [important(style.text(color=text_color, weight="bold"))]
150
+ if highlight_color is not None:
151
+ styles.insert(0, important(style.fill(color=highlight_color)))
152
+ return gt.tab_style(style=styles, locations=loc.body(rows=idx))
153
+
154
+
155
+ def gt_color_results(
156
+ gt: GT,
157
+ result_column: Any = "result",
158
+ *,
159
+ win_color: str = "#5DA271",
160
+ loss_color: str = "#C84630",
161
+ tie_color: str | None = None,
162
+ wins_text_color: str = "white",
163
+ loss_text_color: str = "white",
164
+ tie_text_color: str = "white",
165
+ tie_value: Any = "T",
166
+ result_type: str = "wl",
167
+ ) -> GT:
168
+ """Fill and recolor each row by the win, loss or tie result in one column.
169
+
170
+ Args:
171
+ gt: The table.
172
+ result_column: The column holding the results, as any great_tables selection of exactly one column.
173
+ win_color: Fill of winning rows.
174
+ loss_color: Fill of losing rows.
175
+ tie_color: Fill of tie rows; ``None`` leaves ties alone.
176
+ wins_text_color: Text color of winning rows.
177
+ loss_text_color: Text color of losing rows.
178
+ tie_text_color: Text color of tie rows.
179
+ tie_value: The value marking a tie (used when ``tie_color`` is set).
180
+ result_type: ``"wl"`` for ``"W"``/``"L"`` values, or ``"binary"`` for ``1``/``0``.
181
+
182
+ Returns:
183
+ GT: A new table; rows matching no result keep their styling.
184
+
185
+ Raises:
186
+ TypeError: ``gt`` is not a ``GT``.
187
+ ValueError: ``result_column`` does not select exactly one column, or ``result_type`` is unknown.
188
+
189
+
190
+
191
+ Example:
192
+ ::
193
+
194
+ from great_tables import GT
195
+ import polars as pl
196
+ from sdvplot.great_tables import gt_color_results
197
+
198
+ games = pl.DataFrame({"opp": ["BUF", "BAL", "SF"], "result": ["W", "L", "W"]})
199
+
200
+ gt_color_results(GT(games), result_column="result")
201
+
202
+ See Also:
203
+ Ported from sdvplotR ``gt_color_results()``.
204
+ """
205
+ _check_gt(gt)
206
+ if result_type not in ("wl", "binary"):
207
+ raise ValueError(f'result_type must be "wl" or "binary", got {result_type!r}')
208
+ cols = _columns(gt, result_column)
209
+ if len(cols) != 1:
210
+ raise ValueError(f"result_column must select exactly one column; it selected {len(cols)}")
211
+ col = _values(gt, cols[0])
212
+ win, loss = (1, 0) if result_type == "binary" else ("W", "L")
213
+ out = gt
214
+ passes = [(win, win_color, wins_text_color), (loss, loss_color, loss_text_color)]
215
+ if tie_color is not None:
216
+ passes.append((tie_value, tie_color, tie_text_color))
217
+ for value, fill, ink in passes:
218
+ rows = [i for i, v in enumerate(col) if not _is_na(v) and v == value]
219
+ if rows:
220
+ out = out.tab_style(
221
+ style=[important(style.fill(color=fill)), important(style.text(color=ink))],
222
+ locations=loc.body(rows=rows),
223
+ )
224
+ return out
225
+
226
+
227
+ def gt_highlight_cells(
228
+ gt: GT,
229
+ columns: Any,
230
+ condition: Callable[[Any], Any] | Any,
231
+ *,
232
+ fill: str = "#FFF3B0",
233
+ text_color: str | None = None,
234
+ bold: bool = False,
235
+ **text_kwargs: Any,
236
+ ) -> GT:
237
+ """Fill the individual cells of a block of columns that meet a condition.
238
+
239
+ Each selected column is tested and filled on its own, so the filled cells can form a diagonal, a checker or
240
+ any scatter (one ``tab_style`` over ``loc.body`` would fill a whole rectangle).
241
+
242
+ Args:
243
+ gt: The table.
244
+ columns: The block of columns to test.
245
+ condition: A callable applied to each column's data (a pandas or polars Series, as the table holds) that
246
+ returns one boolean per row, such as ``lambda s: s > 0.7``; or a mask computed ahead of time with one
247
+ column per selected column (a pandas or polars DataFrame, or a 2-D sequence or array of rows).
248
+ Missing counts as not matched.
249
+ fill: Fill of the matching cells.
250
+ text_color: Text color of the matching cells; ``None`` leaves it alone.
251
+ bold: Bold the matching cells.
252
+ **text_kwargs: Passed to great_tables ``style.text`` for the matching cells, such as ``style="italic"``.
253
+
254
+ Returns:
255
+ GT: A new table with the matching cells filled.
256
+
257
+ Raises:
258
+ TypeError: ``gt`` is not a ``GT``.
259
+ ValueError: ``columns`` matches nothing, the mask has the wrong number of columns, or ``condition`` fails
260
+ on a column or does not return one value per row.
261
+
262
+
263
+
264
+ Example:
265
+ ::
266
+
267
+ from great_tables import GT
268
+ import polars as pl
269
+ from sdvplot.great_tables import gt_highlight_cells
270
+
271
+ cor_df = pl.DataFrame({"var": ["mpg", "hp"], "mpg": [1.0, -0.78], "hp": [-0.78, 1.0]})
272
+
273
+ gt_highlight_cells(GT(cor_df, rowname_col="var"), ["mpg", "hp"], lambda s: s > 0.7, fill="#FFD1A9")
274
+
275
+ See Also:
276
+ Ported from sdvplotR ``gt_highlight_cells()``.
277
+ """
278
+ _check_gt(gt)
279
+ cols = _columns(gt, columns)
280
+ if not cols:
281
+ raise ValueError("columns matched no columns")
282
+ masks = _condition_masks(gt, cols, condition)
283
+ text_args = dict(text_kwargs)
284
+ if text_color is not None:
285
+ text_args["color"] = text_color
286
+ if bold:
287
+ text_args["weight"] = "bold"
288
+ styles: list[Any] = [important(style.fill(color=fill))]
289
+ if text_args:
290
+ styles.append(important(style.text(**text_args)))
291
+ out = gt
292
+ for c in cols:
293
+ rows = [i for i, hit in enumerate(masks[c]) if hit]
294
+ if rows:
295
+ out = out.tab_style(style=styles, locations=loc.body(columns=c, rows=rows))
296
+ return out
297
+
298
+
299
+ def _condition_masks(gt: GT, cols: list[str], condition: Any) -> dict[str, list[bool]]:
300
+ frame = _frame(gt)
301
+ if callable(condition):
302
+ masks = {}
303
+ for c in cols:
304
+ try:
305
+ out = condition(frame[c].to_native())
306
+ except Exception as e:
307
+ raise ValueError(
308
+ f"condition could not be applied to column {c!r} ({e}); select only the columns it fits"
309
+ ) from e
310
+ flags = _flags(out)
311
+ if len(flags) != len(frame):
312
+ raise ValueError("condition must return one value per row")
313
+ masks[c] = flags
314
+ return masks
315
+ try:
316
+ mask = nw.from_native(condition, eager_only=True)
317
+ by_column = [list(mask[name].to_list()) for name in mask.columns]
318
+ except TypeError:
319
+ rows = [list(r) for r in condition]
320
+ width = len(rows[0]) if rows else 0
321
+ if any(len(r) != width for r in rows):
322
+ raise ValueError("a mask condition must have the same number of values in every row") from None
323
+ by_column = [[r[j] for r in rows] for j in range(width)]
324
+ if len(by_column) != len(cols):
325
+ raise ValueError(
326
+ f"a mask condition must have one column per selected column: got {len(by_column)} for {len(cols)}"
327
+ )
328
+ return {c: _flags(v) for c, v in zip(cols, by_column, strict=True)}
329
+
330
+
331
+ def gt_highlight_na(
332
+ gt: GT,
333
+ columns: Any = None,
334
+ *,
335
+ fill: str | None = "#F0F0F0",
336
+ text_color: str | None = None,
337
+ bold: bool = False,
338
+ italic: bool = False,
339
+ missing_text: str | None = None,
340
+ na_strings: str | Sequence[str] = "NA",
341
+ ignore_case: bool = False,
342
+ **text_kwargs: Any,
343
+ ) -> GT:
344
+ """Style, and optionally relabel, missing values.
345
+
346
+ Missing means a real null/NaN or a value whose trimmed text is one of ``na_strings`` (a CSV's ``"NA"``,
347
+ by default).
348
+
349
+ Args:
350
+ gt: The table.
351
+ columns: The columns to check; ``None`` checks every body column.
352
+ fill: Fill behind missing values; ``None`` for no fill.
353
+ text_color: Text color of missing values.
354
+ bold: Bold missing values.
355
+ italic: Italicize missing values.
356
+ missing_text: Replacement text for missing values, such as ``"--"``; ``None`` leaves the text alone.
357
+ na_strings: Strings treated as missing alongside real nulls.
358
+ ignore_case: Match ``na_strings`` case-insensitively.
359
+ **text_kwargs: Passed to great_tables ``style.text``.
360
+
361
+ Returns:
362
+ GT: A new table with missing values styled.
363
+
364
+ Raises:
365
+ TypeError: ``gt`` is not a ``GT``.
366
+ ValueError: ``columns`` names a column the table lacks.
367
+
368
+ Example:
369
+ ::
370
+
371
+ from great_tables import GT
372
+ import polars as pl
373
+ from sdvplot.great_tables import gt_highlight_na
374
+
375
+ df = pl.DataFrame({"day": [1, 2, 3], "ozone": [41.0, None, 28.0], "solar": [190.0, 118.0, None]})
376
+
377
+ gt_highlight_na(GT(df), ["ozone", "solar"], missing_text="not recorded", italic=True)
378
+
379
+ See Also:
380
+ Ported from sdvplotR ``gt_highlight_na()``.
381
+ """
382
+ _check_gt(gt)
383
+ wanted = [na_strings] if isinstance(na_strings, str) else list(na_strings)
384
+ if ignore_case:
385
+ wanted = [s.lower() for s in wanted]
386
+
387
+ def missing(v: Any) -> bool:
388
+ if _is_na(v):
389
+ return True
390
+ text = str(v).strip()
391
+ return (text.lower() if ignore_case else text) in wanted
392
+
393
+ text_args = {
394
+ k: v
395
+ for k, v in {
396
+ "color": text_color,
397
+ "weight": "bold" if bold else None,
398
+ "style": "italic" if italic else None,
399
+ }.items()
400
+ if v is not None
401
+ } | text_kwargs
402
+ styles: list[Any] = []
403
+ if fill is not None:
404
+ styles.append(important(style.fill(color=fill)))
405
+ if text_args:
406
+ styles.append(important(style.text(**text_args)))
407
+
408
+ out = gt
409
+ for col in _columns(gt, columns):
410
+ rows = [i for i, v in enumerate(_values(gt, col)) if missing(v)]
411
+ if not rows:
412
+ continue
413
+ if styles:
414
+ out = out.tab_style(style=styles, locations=loc.body(columns=col, rows=rows))
415
+ if missing_text is not None:
416
+ # fmt, not text_transform: great_tables skips null cells in text_transform
417
+ out = out.fmt(_constant(missing_text), columns=col, rows=rows)
418
+ return out
419
+
420
+
421
+ def gt_group_stripes(gt: GT, color: str = "#F5F5F5", start: int = 2, include_stub: bool = True) -> GT:
422
+ """Shade every other row group, so each group reads as a block.
423
+
424
+ Groups are banded in the order they render (``GT.row_group_order``), not the order they appear in the data.
425
+ Group heading rows are left alone.
426
+
427
+ Args:
428
+ gt: The table. It must have row groups (``GT(data, groupname_col=...)``).
429
+ color: Fill of the banded groups.
430
+ start: ``2`` leaves the first group unshaded, ``1`` shades it.
431
+ include_stub: Band the stub column along with the body.
432
+
433
+ Returns:
434
+ GT: A new table with alternate groups banded; ``gt`` itself, with one SdvplotWarning, when the table has
435
+ no row groups.
436
+
437
+ Raises:
438
+ TypeError: ``gt`` is not a ``GT``.
439
+ ValueError: ``start`` is not 1 or 2.
440
+
441
+ Example:
442
+ ::
443
+
444
+ from great_tables import GT
445
+ import polars as pl
446
+ from sdvplot.great_tables import gt_group_stripes
447
+
448
+ df = pl.DataFrame({"conference": ["AFC", "AFC", "NFC", "NFC"], "team": ["KC", "BUF", "SF", "DAL"]})
449
+
450
+ gt_group_stripes(GT(df, groupname_col="conference"), color="#FBF3E4", start=1)
451
+
452
+ See Also:
453
+ Ported from sdvplotR ``gt_group_stripes()``.
454
+ """
455
+ _check_gt(gt)
456
+ groups = list(gt._stub.group_rows)
457
+ if not groups:
458
+ _warn("gt_group_stripes needs a table with row groups: build it with GT(data, groupname_col=...)")
459
+ return gt
460
+ if start not in (1, 2):
461
+ raise ValueError(f"start must be 1 or 2, got {start!r}")
462
+ rows = sorted(int(i) for group in groups[start - 1 :: 2] for i in group.indices) # pandas gives numpy ints
463
+ if not rows:
464
+ return gt
465
+ out = gt.tab_style(style=important(style.fill(color=color)), locations=loc.body(rows=rows))
466
+ if include_stub and any(c.type.name == "stub" for c in gt._boxhead):
467
+ out = out.tab_style(style=important(style.fill(color=color)), locations=loc.stub(rows=rows))
468
+ return out
469
+
470
+
471
+ # ---------------------------------------------------------------------------------------------------------------------
472
+ # borders, bars, rules and captions: scoped CSS keyed on the table id
473
+
474
+ _borders = style.borders # gt_cutline's ``style`` argument shadows the great_tables module
475
+
476
+
477
+ def _md(text: str) -> str:
478
+ """Markdown to HTML, as great_tables renders ``md()`` (raw HTML passes through)."""
479
+ return _process_text(md(text)).strip()
480
+
481
+
482
+ def gt_538_caption(
483
+ gt: GT,
484
+ *,
485
+ top_caption: str | None = None,
486
+ bottom_caption: str | None = None,
487
+ rule_color: str | None = None,
488
+ rule_width: float = 1,
489
+ size: float = 12,
490
+ align: str = "right",
491
+ ) -> GT:
492
+ """Add a FiveThirtyEight-style caption under the table: a top caption over a rule, then a bottom caption.
493
+
494
+ Both captions accept markdown (and raw HTML). great_tables renders source notes above footnotes, so both
495
+ captions are source notes (sdvplotR makes the top one a footnote): the top one carries the rule and ``size``,
496
+ the bottom one is aligned by ``align``. Apply the theme first: the rule color is read off the rendered table.
497
+
498
+ Args:
499
+ gt: The table.
500
+ top_caption: Text above the rule; ``None`` leaves out the rule too.
501
+ bottom_caption: Text below the rule.
502
+ rule_color: The rule's color; ``None`` takes the first text color in the rendered table (so it tracks a
503
+ dark theme; a ``background-color`` or ``border-*-color`` is never taken), else a neutral gray.
504
+ rule_width: The rule width in pixels.
505
+ size: The top caption's font size in pixels.
506
+ align: The bottom caption's alignment: ``"left"``, ``"center"`` or ``"right"``.
507
+
508
+ Returns:
509
+ GT: A new table with the captions.
510
+
511
+ Raises:
512
+ TypeError: ``gt`` is not a ``GT``.
513
+ ValueError: Neither caption was given, or ``align`` is unknown.
514
+
515
+ Example:
516
+ ::
517
+
518
+ from great_tables import GT
519
+ import polars as pl
520
+ from sdvplot.great_tables import gt_538_caption
521
+
522
+ df = pl.DataFrame(
523
+ {"car": ["Mazda", "Datsun", "Hornet"], "mpg": [21.0, 22.8, 18.7], "hp": [110, 93, 175],
524
+ "disp": [160.0, 108.0, 360.0]}
525
+ )
526
+
527
+ gt_538_caption(GT(df), top_caption="Fuel economy and power", bottom_caption="Source: *Motor Trend*")
528
+
529
+ See Also:
530
+ Ported from sdvplotR ``gt_538_caption()``.
531
+ """
532
+ _check_gt(gt)
533
+ if top_caption is None and bottom_caption is None:
534
+ raise ValueError("nothing to caption: pass top_caption (above the rule), bottom_caption (below it), or both")
535
+ _one_of("align", align, ("left", "center", "right"))
536
+ if rule_color is None: # a bare `color:` declaration, not the tail of `background-color:` or `border-*-color:`
537
+ found = re.search(r"(?<![\w-])color:\s(#[0-9A-Fa-f]{6})", gt.as_raw_html())
538
+ rule_color = found.group(1) if found else "#8A8A8A"
539
+ out = gt
540
+ if top_caption is not None:
541
+ rule = f"border-bottom: {rule_width}px solid {rule_color}; font-size: {size}px;"
542
+ out = out.tab_source_note(html(f'<div style="{rule}">{_md(top_caption)}</div>'))
543
+ if bottom_caption is not None:
544
+ out = out.tab_source_note(html(f'<div style="text-align: {align};">{_md(bottom_caption)}</div>'))
545
+ return out
546
+
547
+
548
+ _BAR_ALIGN = {
549
+ "left": "margin-left: 0; margin-right: auto;",
550
+ "center": "margin-left: auto; margin-right: auto;",
551
+ "right": "margin-left: auto; margin-right: 0;",
552
+ }
553
+
554
+
555
+ def _style_font(gt: GT, where: type) -> str | None:
556
+ """The first font set with ``tab_style(style.text(font=...))`` on a location of type ``where``."""
557
+ for info in gt._styles:
558
+ if isinstance(info.locname, where):
559
+ for s in info.styles:
560
+ font = getattr(s, "font", None)
561
+ if isinstance(font, str) and font:
562
+ return font
563
+ return None
564
+
565
+
566
+ def _bars(gt: GT, where: type, colors: str | Sequence[str], **a: Any) -> str:
567
+ """The bar block sdvplotR's ``gt_border_bars_top``/``_bottom`` build, as one HTML string."""
568
+ _one_of("bar_align", a["bar_align"], tuple(_BAR_ALIGN))
569
+ _one_of("img_align", a["img_align"], ("left", "right")) # each becomes a `padding-<side>` property
570
+ _one_of("text_align", a["text_align"], ("left", "right"))
571
+ colors = [colors] if isinstance(colors, str) else list(colors)
572
+ align = _BAR_ALIGN[a["bar_align"]]
573
+ if a["text"] is None and a["img"] is None:
574
+ stack = "".join(f'<div style="height: {a["bar_height"]}px; background-color: {c};"></div>' for c in colors)
575
+ return f'<div style="background-color: transparent; width: {a["bar_width"]}; {align}">{stack}</div>'
576
+ font = _style_font(gt, where)
577
+ head = f"<style>{google_font(font).make_import_stmt()}</style>" if font else ""
578
+ text = (
579
+ f'<span style="font-weight:{a["text_weight"]}; color:{a["text_color"]}; font-size:{a["text_size"]}px; '
580
+ f'padding-{a["text_align"]}: {a["text_padding"]}px; font-family: {font or "inherit"};">{a["text"]}</span>'
581
+ if a["text"] is not None
582
+ else "<span></span>"
583
+ )
584
+ img = (
585
+ f'<img src="{a["img"]}" width="{a["img_width"]}px" height="{a["img_height"]}px" '
586
+ f'style="padding-{a["img_align"]}:{a["img_padding"]}px;" />'
587
+ if a["img"] is not None
588
+ else ""
589
+ )
590
+ return (
591
+ f'{head}<div style="display: flex; justify-content: space-between; align-items: center; '
592
+ f'height: {a["bar_height"]}px; background-color: {colors[0]}; width: {a["bar_width"]}; {align}">'
593
+ f"{text}{img}</div>"
594
+ )
595
+
596
+
597
+ def gt_border_bars_top(
598
+ gt: GT,
599
+ colors: str | Sequence[str],
600
+ *,
601
+ bar_height: float = 10,
602
+ bar_width: str = "100%",
603
+ bar_align: str = "center",
604
+ img: str | None = None,
605
+ img_width: float = 30,
606
+ img_height: float = 30,
607
+ img_padding: float = 10,
608
+ img_align: str = "right",
609
+ text: str | None = None,
610
+ text_weight: str = "bold",
611
+ text_color: str = "#FFFFFF",
612
+ text_size: float = 18,
613
+ text_align: str = "left",
614
+ text_padding: float = 10,
615
+ ) -> GT:
616
+ """Add a row of horizontal color bars at the top of the table, optionally carrying text and an image.
617
+
618
+ With neither ``text`` nor ``img``, each color is its own full-width bar, stacked. With either, one bar in the
619
+ first color holds the text at one end and the image at the other; the text uses the font set on the title
620
+ (imported from Google Fonts) or inherits. great_tables has no ``tab_caption``, so the bars go at the top of
621
+ the heading, above the title: call this after ``tab_header``.
622
+
623
+ Args:
624
+ gt: The table.
625
+ colors: One color per bar (only the first is used with ``text`` or ``img``).
626
+ bar_height: Bar height in pixels.
627
+ bar_width: Width of the bar block, as a CSS width.
628
+ bar_align: ``"left"``, ``"center"`` or ``"right"``, when ``bar_width`` is under 100%.
629
+ img: URL of an image to show in the bar.
630
+ img_width: Image width in pixels.
631
+ img_height: Image height in pixels.
632
+ img_padding: Padding beside the image in pixels.
633
+ img_align: The side of the image the padding goes on (``"left"`` or ``"right"``).
634
+ text: Text to show in the bar.
635
+ text_weight: Font weight of the text.
636
+ text_color: Text color.
637
+ text_size: Text size in pixels.
638
+ text_align: The side of the text the padding goes on (``"left"`` or ``"right"``).
639
+ text_padding: Padding beside the text in pixels.
640
+
641
+ Returns:
642
+ GT: A new table with the bars above its title.
643
+
644
+ Raises:
645
+ TypeError: ``gt`` is not a ``GT``.
646
+ ValueError: ``bar_align``, ``img_align`` or ``text_align`` is not one of its listed values.
647
+
648
+ Example:
649
+ ::
650
+
651
+ from great_tables import GT
652
+ import polars as pl
653
+ from sdvplot.great_tables import gt_border_bars_top
654
+
655
+ df = pl.DataFrame(
656
+ {
657
+ "team": ["KC", "BUF", "BAL"],
658
+ "espn_id": ["3139477", "3918298", "3916387"],
659
+ "wins": [12, 10, 9],
660
+ }
661
+ )
662
+
663
+ gt_border_bars_top(GT(df).tab_header("Standings"), ["#1B7837", "#FFFFFF", "#B2182B"])
664
+
665
+ See Also:
666
+ Ported from sdvplotR ``gt_border_bars_top()``.
667
+ """
668
+ _check_gt(gt)
669
+ bar_args = {k: v for k, v in locals().items() if k not in ("gt", "colors")} # every styling argument
670
+ gt, table_id = _table_id(gt)
671
+ bars = _bars(gt, loc.title, colors, **bar_args)
672
+ heading = gt._heading
673
+ title = "" if heading.title is None else f'<div style="padding: 4px 5px;">{_process_text(heading.title)}</div>'
674
+ subtitle = cast(Any, heading.subtitle) # GTData annotates it BaseText; tab_header stores Text
675
+ return gt.tab_header(title=html(bars + title), subtitle=subtitle, preheader=heading.preheader).opt_css(
676
+ f"#{table_id} .gt_title {{padding: 0px !important;}}"
677
+ )
678
+
679
+
680
+ def gt_border_bars_bottom(
681
+ gt: GT,
682
+ colors: str | Sequence[str],
683
+ *,
684
+ bar_height: float = 10,
685
+ bar_width: str = "100%",
686
+ bar_align: str = "center",
687
+ img: str | None = None,
688
+ img_width: float = 30,
689
+ img_height: float = 30,
690
+ img_padding: float = 10,
691
+ img_align: str = "right",
692
+ text: str | None = None,
693
+ text_weight: str = "bold",
694
+ text_color: str = "#FFFFFF",
695
+ text_size: float = 18,
696
+ text_align: str = "left",
697
+ text_padding: float = 10,
698
+ ) -> GT:
699
+ """Add a row of horizontal color bars below the table, optionally carrying text and an image.
700
+
701
+ The bottom-edge counterpart of ``gt_border_bars_top``: the bars are a source note, and the source notes lose
702
+ their side and bottom padding so the bars reach the table's edges. The text uses the font set on the source
703
+ notes (imported from Google Fonts) or inherits.
704
+
705
+ Args:
706
+ gt: The table.
707
+ colors: One color per bar (only the first is used with ``text`` or ``img``).
708
+ bar_height: Bar height in pixels.
709
+ bar_width: Width of the bar block, as a CSS width.
710
+ bar_align: ``"left"``, ``"center"`` or ``"right"``, when ``bar_width`` is under 100%.
711
+ img: URL of an image to show in the bar.
712
+ img_width: Image width in pixels.
713
+ img_height: Image height in pixels.
714
+ img_padding: Padding beside the image in pixels.
715
+ img_align: The side of the image the padding goes on (``"left"`` or ``"right"``).
716
+ text: Text to show in the bar.
717
+ text_weight: Font weight of the text.
718
+ text_color: Text color.
719
+ text_size: Text size in pixels.
720
+ text_align: The side of the text the padding goes on (``"left"`` or ``"right"``).
721
+ text_padding: Padding beside the text in pixels.
722
+
723
+ Returns:
724
+ GT: A new table with the bars below it.
725
+
726
+ Raises:
727
+ TypeError: ``gt`` is not a ``GT``.
728
+ ValueError: ``bar_align``, ``img_align`` or ``text_align`` is not one of its listed values.
729
+
730
+ Example:
731
+ ::
732
+
733
+ from great_tables import GT
734
+ import polars as pl
735
+ from sdvplot.great_tables import gt_border_bars_bottom
736
+
737
+ df = pl.DataFrame(
738
+ {
739
+ "team": ["KC", "BUF", "BAL"],
740
+ "espn_id": ["3139477", "3918298", "3916387"],
741
+ "wins": [12, 10, 9],
742
+ }
743
+ )
744
+
745
+ gt_border_bars_bottom(GT(df), "#22223B", text="Source: ESPN", bar_height=28)
746
+
747
+ See Also:
748
+ Ported from sdvplotR ``gt_border_bars_bottom()``.
749
+ """
750
+ _check_gt(gt)
751
+ bar_args = {k: v for k, v in locals().items() if k not in ("gt", "colors")} # every styling argument
752
+ gt, table_id = _table_id(gt)
753
+ bars = _bars(gt, loc.source_notes, colors, **bar_args)
754
+ unpad = "padding-right: 0px !important; padding-left: 0px !important; padding-bottom: 0px;"
755
+ return gt.tab_source_note(html(bars)).opt_css(f"#{table_id} .gt_sourcenote {{{unpad}}}")
756
+
757
+
758
+ def gt_border_grid(gt: GT, color: str = "black", weight: float = 1, include_labels: bool = False) -> GT:
759
+ """Draw borders between every column and every row, a full grid.
760
+
761
+ Args:
762
+ gt: The table.
763
+ color: Border color.
764
+ weight: Border thickness in pixels.
765
+ include_labels: Extend the column borders through the column labels.
766
+
767
+ Returns:
768
+ GT: A new table with the grid.
769
+
770
+ Raises:
771
+ TypeError: ``gt`` is not a ``GT``.
772
+
773
+ Example:
774
+ ::
775
+
776
+ from great_tables import GT
777
+ import polars as pl
778
+ from sdvplot.great_tables import gt_border_grid
779
+
780
+ df = pl.DataFrame(
781
+ {
782
+ "team": ["KC", "BUF", "BAL"],
783
+ "espn_id": ["3139477", "3918298", "3916387"],
784
+ "wins": [12, 10, 9],
785
+ }
786
+ )
787
+
788
+ gt_border_grid(GT(df), color="#BBBBBB", weight=2, include_labels=True)
789
+
790
+ See Also:
791
+ Ported from sdvplotR ``gt_border_grid()`` (which uses gtExtras ``gt_add_divider``).
792
+ """
793
+ _check_gt(gt)
794
+ gt, table_id = _table_id(gt)
795
+ visible = [c.var for c in gt._boxhead if c.type.name == "default"]
796
+ out = gt
797
+ if len(visible) > 1: # every column but the last gets a right border (gt_add_divider(columns = -last_col()))
798
+ divider = important(_borders(sides="right", color=color, style="solid", weight=f"{weight}px"))
799
+ where: list[Any] = [loc.body(columns=visible[:-1])]
800
+ if include_labels:
801
+ where.append(loc.column_labels(columns=visible[:-1]))
802
+ out = out.tab_style(style=divider, locations=where)
803
+ return out.opt_css(f"#{table_id} .gt_row {{ border-top-color: {color};}}")
804
+
805
+
806
+ def _cutline_svg(text: str, color: str, size: float) -> str:
807
+ """The label as a URL-encoded inline SVG (sdvplotR's ``.cutline_svg``, byte for byte)."""
808
+ text = str(text).upper()
809
+ esc = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
810
+ tracking = 1.1
811
+ width = len(text) * (size * 0.80 + tracking) + 4
812
+ svg = (
813
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="{width:.0f}" height="{size + 4:.0f}">'
814
+ f'<text x="0" y="{size + 0.5:.1f}" font-family="Helvetica,Arial,sans-serif" '
815
+ f'font-size="{size:g}" font-weight="700" letter-spacing="{tracking:g}" fill="{color}">{esc}</text></svg>'
816
+ )
817
+ return "data:image/svg+xml;charset=utf-8," + quote(svg, safe="")
818
+
819
+
820
+ def gt_cutline( # noqa: C901 - most branches validate its ten arguments; great_tables splits are parked
821
+ gt: GT,
822
+ after: int | Sequence[int],
823
+ *,
824
+ label: str | Sequence[str | None] | None = None,
825
+ color: str = "#A6081A",
826
+ weight: float = 2,
827
+ style: str = "dashed",
828
+ label_color: str | None = None,
829
+ label_size: float = 9,
830
+ label_position: str = "below",
831
+ gap: float | Sequence[float] = 0,
832
+ ) -> GT:
833
+ """Draw a rule across the table after a given row, with an optional label: the cut line of a ranked table.
834
+
835
+ The label is an inline SVG background on the row (CSS pseudo-elements do not survive inlining), so it renders
836
+ in a system sans-serif, and the labeled row's cell fills are cleared (its stripe is repainted on the row).
837
+ Labels assume the table has no row groups. Apply the theme first: the stripe color is read from its options.
838
+
839
+ Args:
840
+ gt: The table.
841
+ after: The number of rows above each line: ``4`` draws between the 4th and 5th rows, ``0`` above the
842
+ first. One whole number or several (numpy integers included).
843
+ label: A label per line, recycled against ``after``; ``None`` in a list leaves that line unlabeled. Drawn
844
+ in uppercase.
845
+ color: The rule color.
846
+ weight: The rule thickness in pixels.
847
+ style: ``"dashed"``, ``"solid"`` or ``"dotted"``.
848
+ label_color: The label color; ``None`` uses ``color``.
849
+ label_size: The label size in pixels.
850
+ label_position: ``"below"`` or ``"above"`` the line.
851
+ gap: Extra space in pixels around each line: one number for both sides, or ``(above, below)``.
852
+
853
+ Returns:
854
+ GT: A new table with the lines; ``gt`` itself, with one SdvplotWarning, when every line is out of range.
855
+
856
+ Raises:
857
+ TypeError: ``gt`` is not a ``GT``.
858
+ ValueError: ``after`` is not numeric or not a whole number, ``gap`` is not one or two non-negative
859
+ numbers, or ``style`` or ``label_position`` is unknown.
860
+
861
+ Example:
862
+ ::
863
+
864
+ from great_tables import GT
865
+ import polars as pl
866
+ from sdvplot.great_tables import gt_cutline
867
+
868
+ standings = pl.DataFrame(
869
+ {
870
+ "team": ["T1", "T2", "T3", "T4", "T5", "T6", "T7", "T8"],
871
+ "wins": [14, 13, 12, 11, 10, 9, 8, 7],
872
+ }
873
+ )
874
+
875
+ gt_cutline(GT(standings), after=6, label="Playoff line")
876
+
877
+ See Also:
878
+ Ported from sdvplotR ``gt_cutline()``.
879
+ """
880
+ _check_gt(gt)
881
+ if label_position not in ("below", "above"):
882
+ raise ValueError(f'label_position must be "below" or "above", got {label_position!r}')
883
+ _one_of("style", style, ("dashed", "solid", "dotted"))
884
+ # numbers.Real, not int | float: numpy integers are neither, and list() of one raises a TypeError
885
+ afters: list[Any] = [after] if isinstance(after, Real) else list(cast(Sequence[Any], after))
886
+ if not afters:
887
+ return gt
888
+ if not all(isinstance(a, Real) and not isinstance(a, bool) for a in afters):
889
+ raise ValueError("after must be numeric row numbers")
890
+ if not all(float(a).is_integer() for a in afters):
891
+ raise ValueError(f"after must be whole row numbers, got {[float(a) for a in afters]}")
892
+ afters = [int(a) for a in afters]
893
+ gaps: list[Any] = [gap] if isinstance(gap, Real) else list(cast(Sequence[Any], gap))
894
+ if not 1 <= len(gaps) <= 2 or any(not isinstance(g, Real) or g < 0 for g in gaps):
895
+ raise ValueError("gap must be one or two non-negative numbers")
896
+ above_gap, below_gap = gaps[0], gaps[-1]
897
+
898
+ labels: list[str | None] | None = None
899
+ if label is not None:
900
+ given = [label] if isinstance(label, str) else list(label)
901
+ labels = [given[i % len(given)] for i in range(len(afters))]
902
+ n_rows = len(_frame(gt))
903
+ keep = [0 <= a < n_rows for a in afters]
904
+ if not all(keep):
905
+ dropped = [a for a, k in zip(afters, keep, strict=True) if not k]
906
+ _warn(
907
+ f"dropped {len(dropped)} cut line(s) at {dropped}: after must be between 0 and {n_rows - 1}; "
908
+ "a line after the last row is just the table border"
909
+ )
910
+ afters = [a for a, k in zip(afters, keep, strict=True) if k]
911
+ labels = None if labels is None else [x for x, k in zip(labels, keep, strict=True) if k]
912
+ if not afters:
913
+ return gt
914
+ label_color = label_color or color
915
+ gt, table_id = _table_id(gt)
916
+
917
+ # the rule is a top border on the row below the cut (0-based row `a` is R's row a + 1)
918
+ out = gt
919
+ for a in afters:
920
+ rule = important(_borders(sides="top", weight=f"{weight}px", color=color, style=style))
921
+ out = out.tab_style(style=rule, locations=loc.body(rows=[int(a)]))
922
+
923
+ below = label_position == "below"
924
+ css: list[str] = []
925
+ # room around each rule on the flanking rows (CSS nth-child is 1-based); a labeled side folds its share into
926
+ # the label's padding instead
927
+ for i, a in enumerate(afters):
928
+ labeled = labels is not None and bool(labels[i])
929
+ label_row = 1 if a == 0 else (a + 1 if below else a)
930
+ for r, side, value in ((a, "bottom", above_gap), (a + 1, "top", below_gap)):
931
+ if value <= 0 or r < 1 or r > n_rows or (labeled and r == label_row):
932
+ continue
933
+ css.append(f"#{table_id} tbody tr:nth-child({r:g}) td {{ padding-{side}: {value:g}px !important; }}")
934
+
935
+ if labels is not None:
936
+ opts = out._options
937
+ striping = bool(opts.row_striping_include_table_body.value)
938
+ stripe = opts.row_striping_background_color.value
939
+ for a, lab in zip(afters, labels, strict=True):
940
+ if not lab:
941
+ continue
942
+ row, side, pos = (a + 1, "top", "left 5px") if below else (a, "bottom", "left bottom 5px")
943
+ if row < 1: # nothing above row 1 to sit under, so the label drops into its top
944
+ row, side, pos = 1, "top", "left 5px"
945
+ pad = label_size + 13 + (below_gap if side == "top" else above_gap)
946
+ row_bg = f"background-color: {stripe}; " if striping and row % 2 == 0 and stripe else ""
947
+ css.append(
948
+ f"#{table_id} tbody tr:nth-child({row:g}) td {{ padding-{side}: {pad:g}px !important; "
949
+ "background-color: transparent !important; }"
950
+ )
951
+ css.append(
952
+ f'#{table_id} tbody tr:nth-child({row:g}) {{ {row_bg}background-image: url("'
953
+ f'{_cutline_svg(lab, label_color, label_size)}"); '
954
+ f"background-repeat: no-repeat; background-position: {pos}; }}"
955
+ )
956
+ return out.opt_css("\n".join(css)) if css else out
957
+
958
+
959
+ # ---------------------------------------------------------------------------------------------------------------------
960
+ # color scales, pills and boxes
961
+
962
+
963
+ def _number(v: Any) -> float | None:
964
+ """R's ``as.numeric`` without the warning: numbers, booleans and numeric strings; anything else is None."""
965
+ if _is_na(v) or (isinstance(v, str) and not v.strip()):
966
+ return None
967
+ try:
968
+ f = float(v)
969
+ except (TypeError, ValueError):
970
+ return None
971
+ return None if math.isnan(f) else f
972
+
973
+
974
+ def _natural(v: float, big: bool = False) -> str:
975
+ """R's ``format(v, trim = TRUE, scientific = FALSE)``: up to 7 significant digits, never scientific."""
976
+ if math.isinf(v):
977
+ return "Inf" if v > 0 else "-Inf"
978
+ exponent = Decimal(f"{v:.7g}").normalize().as_tuple().exponent
979
+ decimals = max(0, -exponent) if isinstance(exponent, int) else 0
980
+ return f"{v:{',' if big else ''}.{decimals}f}"
981
+
982
+
983
+ def _format_value(value: float | None, digits: int | None, format_type: str, suffix: str) -> str:
984
+ """sdvplotR's pill and box label for an already-scaled value: ``number``, ``comma``, ``currency`` or ``percent``.
985
+
986
+ A missing value prints as ``NA``, as R's ``format(NA)`` does.
987
+ """
988
+ big = format_type in ("comma", "currency")
989
+ if value is None:
990
+ core = "NA"
991
+ elif digits is None:
992
+ core = _natural(value, big)
993
+ else:
994
+ core = f"{value:{',' if big else ''}.{digits}f}"
995
+ if format_type == "currency":
996
+ core = "$" + core
997
+ elif format_type == "percent":
998
+ core += "%"
999
+ return core + suffix
1000
+
1001
+
1002
+ _FORMAT_TYPES = ("number", "comma", "currency", "percent")
1003
+
1004
+
1005
+ def _fmt_rows(gt: GT, column: str, cells: dict[int, str]) -> GT:
1006
+ """Put per-row HTML into a column (sdvplotR's ``.fmt_rows``).
1007
+
1008
+ great_tables formatters see a cell's value, not its row, so this adds one ``fmt`` per distinct string, over the
1009
+ rows that carry it. Formatting by data row keeps row groups and sorting from scrambling the cells.
1010
+ """
1011
+ by_html: dict[str, list[int]] = {}
1012
+ for row, text in cells.items():
1013
+ by_html.setdefault(text, []).append(row)
1014
+ for text, rows in by_html.items():
1015
+ gt = gt.fmt(_constant(text), columns=column, rows=rows)
1016
+ return gt
1017
+
1018
+
1019
+ def _palette(palette: Sequence[str], pal_type: str) -> list[str]:
1020
+ """The palette as ``#rrggbb`` strings; a ``str`` (a named or paletteer palette) is refused."""
1021
+ if pal_type not in ("discrete", "continuous"):
1022
+ raise ValueError(f'pal_type must be "discrete" or "continuous", got {pal_type!r}')
1023
+ if isinstance(palette, str):
1024
+ raise ValueError(f"palette must be a list of hex colors; named palettes like {palette!r} are not supported")
1025
+ colors = [hex6(c) for c in palette]
1026
+ if not colors:
1027
+ raise ValueError("palette needs at least one color")
1028
+ return colors
1029
+
1030
+
1031
+ def _ramp(palette: list[str], domain: tuple[float, float]) -> Callable[[float], str | None]:
1032
+ """Piecewise-linear sRGB between evenly spaced stops over ``domain``, as great_tables' ``data_color``.
1033
+
1034
+ Returns ``None`` for a value outside the domain.
1035
+ """
1036
+ lo, hi = domain
1037
+ k = len(palette) - 1
1038
+
1039
+ def color(v: float) -> str | None:
1040
+ if not lo <= v <= hi:
1041
+ return None
1042
+ if k == 0:
1043
+ return palette[0]
1044
+ t = 0.5 if hi == lo else (v - lo) / (hi - lo)
1045
+ i = min(int(t * k), k - 1)
1046
+ return mix(palette[i], palette[i + 1], t * k - i)
1047
+
1048
+ return color
1049
+
1050
+
1051
+ def _ranks(values: list[float | None], descending: bool) -> list[float | None]:
1052
+ """R's ``rank(ties.method = "average", na.last = "keep")``, flipped so the largest value ranks 1 when descending."""
1053
+ order = sorted((v, i) for i, v in enumerate(values) if v is not None)
1054
+ ranks: list[float | None] = [None] * len(values)
1055
+ j = 0
1056
+ while j < len(order):
1057
+ k = j
1058
+ while k + 1 < len(order) and order[k + 1][0] == order[j][0]:
1059
+ k += 1
1060
+ for t in range(j, k + 1):
1061
+ ranks[order[t][1]] = (j + k) / 2 + 1
1062
+ j = k + 1
1063
+ if descending and order:
1064
+ top = max(r for r in ranks if r is not None)
1065
+ ranks = [None if r is None else top - r + 1 for r in ranks]
1066
+ return ranks
1067
+
1068
+
1069
+ def _domain(columns: dict[str, list[float | None]], domain: Sequence[float] | None) -> tuple[float, float]:
1070
+ if domain is not None:
1071
+ return float(domain[0]), float(domain[1])
1072
+ present = [v for values in columns.values() for v in values if v is not None]
1073
+ if not present:
1074
+ raise ValueError("the columns hold no numeric values to color; pass domain")
1075
+ return min(present), max(present)
1076
+
1077
+
1078
+ def _record_scale(
1079
+ gt: GT, columns: list[str], palette: Sequence[str], domain: tuple[float, float], reverse: bool, pal_type: str
1080
+ ) -> GT:
1081
+ """Leave the scale on a copy of the table for ``gt_legend_continuous`` (the shared ``_sdvplot_scale`` record)."""
1082
+ out = copy.copy(gt)
1083
+ out.__dict__["_sdvplot_scale"] = {
1084
+ "columns": list(columns),
1085
+ "palette": list(palette),
1086
+ "domain": domain,
1087
+ "reverse": reverse,
1088
+ "pal_type": pal_type,
1089
+ }
1090
+ return out
1091
+
1092
+
1093
+ def gt_color_pills(
1094
+ gt: GT,
1095
+ columns: Any,
1096
+ *,
1097
+ rows: Any = None,
1098
+ palette: Sequence[str] = ("#C84630", "#5DA271"),
1099
+ fill_type: str = "continuous",
1100
+ rank_order: str = "desc",
1101
+ digits: int | None = None,
1102
+ domain: Sequence[float] | None = None,
1103
+ format_type: str = "number",
1104
+ scale_percent: bool = True,
1105
+ suffix: str = "",
1106
+ reverse: bool = False,
1107
+ outline_color: str | None = None,
1108
+ outline_width: float = 0.25,
1109
+ pal_type: str = "discrete",
1110
+ pill_height: float = 25,
1111
+ text_color: str | None = None,
1112
+ na_color: str | None = None,
1113
+ ) -> GT:
1114
+ """Show values as rounded pills filled from a palette, by value or by rank.
1115
+
1116
+ Several columns share one ``domain`` (taken from them all when unset, with a warning) so their colors compare;
1117
+ pill width is set per column. With ``fill_type="rank"`` each column is ranked against itself (average ties).
1118
+ The text is black or white, whichever reads better on the fill, unless ``text_color`` is set. A value outside
1119
+ ``domain`` is drawn grey (``#808080``) with one warning. The scale is recorded for ``gt_legend_continuous``.
1120
+
1121
+ Args:
1122
+ gt: The table.
1123
+ columns: The columns to fill.
1124
+ rows: The rows to fill, as great_tables' ``loc.body(rows=)`` takes them; the rest keep their value.
1125
+ ``None`` fills every row.
1126
+ palette: Hex colors, low to high.
1127
+ fill_type: ``"continuous"`` (by value) or ``"rank"``.
1128
+ rank_order: ``"desc"`` (the largest value ranks 1) or ``"asc"``, for ``fill_type="rank"``.
1129
+ digits: Decimal places of the printed value; ``None`` prints it naturally.
1130
+ domain: ``(low, high)`` mapped onto the palette; ``None`` uses the observed range and warns.
1131
+ format_type: ``"number"``, ``"comma"``, ``"currency"`` or ``"percent"``.
1132
+ scale_percent: Multiply by 100 for ``format_type="percent"``.
1133
+ suffix: Appended to each printed value, such as ``"M"``.
1134
+ reverse: Reverse the palette.
1135
+ outline_color: A border color around each pill; ``None`` for none.
1136
+ outline_width: The border width in pixels.
1137
+ pal_type: ``"discrete"`` or ``"continuous"``; recorded for the legend only (sdvplotR uses it to look up
1138
+ paletteer palettes, which Python does not have).
1139
+ pill_height: Pill height in pixels.
1140
+ text_color: The pill text color; ``None`` picks black or white per pill.
1141
+ na_color: A hex color for a pill over a missing value (a translucent ``#rrggbbaa`` is drawn as given);
1142
+ ``None`` leaves the cell blank.
1143
+
1144
+ Returns:
1145
+ GT: A new table with pills, recording ``_sdvplot_scale``; ``gt`` itself, with one SdvplotWarning, when
1146
+ ``rows`` matches nothing.
1147
+
1148
+ Raises:
1149
+ TypeError: ``gt`` is not a ``GT``.
1150
+ ValueError: ``columns`` matches nothing; the palette is not a list of hex colors; an option is not one
1151
+ of its listed values; or there is no ``domain`` and no numeric value to derive one from.
1152
+
1153
+ Example:
1154
+ ::
1155
+
1156
+ from great_tables import GT
1157
+ import polars as pl
1158
+ from sdvplot.great_tables import gt_color_pills
1159
+
1160
+ df = pl.DataFrame(
1161
+ {"car": ["Mazda", "Datsun", "Hornet"], "mpg": [21.0, 22.8, 18.7], "hp": [110, 93, 175],
1162
+ "disp": [160.0, 108.0, 360.0]}
1163
+ )
1164
+
1165
+ gt_color_pills(GT(df), ["disp", "hp"], domain=(50, 500))
1166
+ gt_color_pills(GT(df), "hp", fill_type="rank", domain=(1, 6), digits=0)
1167
+
1168
+ See Also:
1169
+ Ported from sdvplotR ``gt_color_pills()``; ``gt_color_ranks`` fills the whole cell.
1170
+ """
1171
+ _check_gt(gt)
1172
+ cols = _columns(gt, columns)
1173
+ if not cols:
1174
+ raise ValueError("columns matched no columns")
1175
+ for name, value, allowed in (
1176
+ ("fill_type", fill_type, ("continuous", "rank")),
1177
+ ("rank_order", rank_order, ("asc", "desc")),
1178
+ ("format_type", format_type, _FORMAT_TYPES),
1179
+ ):
1180
+ if value not in allowed:
1181
+ raise ValueError(f"{name} must be one of {allowed}, got {value!r}")
1182
+ pal = _palette(palette, pal_type)
1183
+ keep = _kept_rows(gt, rows)
1184
+ if keep is None:
1185
+ return gt
1186
+
1187
+ numbers = {c: [_number(v) for v in _values(gt, c)] for c in cols}
1188
+ scaled = {c: _ranks(v, rank_order == "desc") if fill_type == "rank" else v for c, v in numbers.items()}
1189
+ lo, hi = _domain(scaled, domain)
1190
+ if domain is None:
1191
+ _warn(
1192
+ f"no domain given, so the colors span the observed range ({_natural(lo)} to {_natural(hi)}); "
1193
+ "set domain to compare colors across tables or columns"
1194
+ )
1195
+ ramp = _ramp(pal[::-1] if reverse else pal, (lo, hi))
1196
+ outline = f"border: {outline_width}px solid {outline_color};" if outline_color is not None else ""
1197
+
1198
+ def label(v: float | None) -> str:
1199
+ if v is not None and format_type == "percent" and scale_percent:
1200
+ v *= 100
1201
+ return _format_value(v, digits, format_type, suffix)
1202
+
1203
+ out, outside, bg = gt, 0, _background(gt)
1204
+ for c in cols:
1205
+ width = max((len(label(numbers[c][i])) for i in keep), default=1)
1206
+ cells: dict[int, str] = {}
1207
+ for i in keep:
1208
+ s = scaled[c][i]
1209
+ if s is None:
1210
+ if na_color is None:
1211
+ cells[i] = ""
1212
+ continue
1213
+ fill, text = na_color, ""
1214
+ else:
1215
+ ramped = ramp(s)
1216
+ if ramped is None:
1217
+ outside += 1
1218
+ fill, text = ramped or "#808080", label(numbers[c][i])
1219
+ ink = text_color or on_color(solid(fill, bg)) # na_color goes to CSS as given; measure what shows
1220
+ cells[i] = (
1221
+ f"<span style='display: inline-block; width: {width}ch; padding-left: 3px; padding-right: 3px; "
1222
+ f"height: {pill_height}px; line-height: {pill_height}px; background-color: {fill}; color: {ink}; "
1223
+ f"border-radius: 10px; text-align: center; {outline}'>{text}</span>"
1224
+ )
1225
+ out = _fmt_rows(out, c, cells)
1226
+ if outside:
1227
+ _warn(f"{outside} value(s) fall outside the domain ({_natural(lo)} to {_natural(hi)}) and are drawn grey")
1228
+ return _record_scale(out, cols, palette, (lo, hi), reverse, pal_type)
1229
+
1230
+
1231
+ def gt_color_ranks(
1232
+ gt: GT,
1233
+ columns: Any,
1234
+ *,
1235
+ rows: Any = None,
1236
+ palette: Sequence[str] = ("#3D8B6E", "#9DC5A7", "#EDE0CC", "#DB9070", "#BE4D3A"),
1237
+ domain: Sequence[float] | None = None,
1238
+ reverse: bool = False,
1239
+ na_color: str = "white",
1240
+ autocolor_text: bool = True,
1241
+ pal_type: str = "discrete",
1242
+ **data_color_kwargs: Any,
1243
+ ) -> GT:
1244
+ """Fill the cells of columns that already hold ranks (1 is best), green to red by default.
1245
+
1246
+ A shorthand around great_tables' ``data_color``: the values are colored as they are, no ranking is computed.
1247
+ The domain is shared across the columns (rank 1 and the largest rank present anchor the ends) unless given.
1248
+ The scale is recorded for ``gt_legend_continuous``.
1249
+
1250
+ Args:
1251
+ gt: The table.
1252
+ columns: The columns to color.
1253
+ rows: The rows to color, as great_tables' ``loc.body(rows=)`` takes them; ``None`` colors every row.
1254
+ palette: Hex colors, low to high.
1255
+ domain: ``(low, high)`` mapped onto the palette; ``None`` uses the selected columns' range.
1256
+ reverse: Reverse the palette.
1257
+ na_color: The fill of missing values.
1258
+ autocolor_text: Set each cell's text to black or white for contrast.
1259
+ pal_type: ``"discrete"`` or ``"continuous"``; recorded for the legend only.
1260
+ **data_color_kwargs: Passed to ``GT.data_color`` (``alpha``, ``truncate``).
1261
+
1262
+ Returns:
1263
+ GT: A new table, recording ``_sdvplot_scale``; ``gt`` itself, with one SdvplotWarning, when ``rows``
1264
+ matches nothing.
1265
+
1266
+ Raises:
1267
+ TypeError: ``gt`` is not a ``GT``.
1268
+ ValueError: ``columns`` matches nothing, the palette is not a list of hex colors, or there is no
1269
+ ``domain`` and no numeric value to derive one from.
1270
+
1271
+
1272
+ Warns:
1273
+ SdvplotWarning: When the table stripes its rows in a VS Code or Positron notebook, where great_tables makes
1274
+ the stripes ``!important`` and they cover ``data_color``'s fills and text color on every other row.
1275
+
1276
+ Example:
1277
+ ::
1278
+
1279
+ from great_tables import GT
1280
+ import polars as pl
1281
+ from sdvplot.great_tables import gt_color_ranks
1282
+
1283
+ ranked = pl.DataFrame({"team": ["KC", "BUF", "BAL"], "off_rank": [1, 3, 2], "def_rank": [5, 2, 1]})
1284
+
1285
+ gt_color_ranks(GT(ranked), ["off_rank", "def_rank"])
1286
+
1287
+ See Also:
1288
+ Ported from sdvplotR ``gt_color_ranks()``; ``gt_color_pills`` draws pills instead.
1289
+ """
1290
+ _check_gt(gt)
1291
+ cols = _columns(gt, columns)
1292
+ if not cols:
1293
+ raise ValueError("columns matched no columns")
1294
+ _palette(palette, pal_type)
1295
+ keep = _kept_rows(gt, rows)
1296
+ if keep is None:
1297
+ return gt
1298
+ lo, hi = _domain({c: [_number(v) for v in _values(gt, c)] for c in cols}, domain)
1299
+ _warn_striped(gt, "gt_color_ranks", "fills and text color")
1300
+ out = gt.data_color(
1301
+ columns=cols,
1302
+ rows=keep,
1303
+ palette=list(palette),
1304
+ domain=[lo, hi],
1305
+ na_color=na_color,
1306
+ reverse=reverse,
1307
+ autocolor_text=autocolor_text,
1308
+ **data_color_kwargs,
1309
+ )
1310
+ return _record_scale(out, cols, palette, (lo, hi), reverse, pal_type)
1311
+
1312
+
1313
+ def _takes_column(rule: Callable[..., Any]) -> bool:
1314
+ """Whether an indicator rule takes the column name as a second argument (R: ``length(formals(rule)) == 2``)."""
1315
+ try:
1316
+ return len(inspect.signature(rule).parameters) == 2
1317
+ except (TypeError, ValueError):
1318
+ return False
1319
+
1320
+
1321
+ def _box_label(v: float | None, digits: int | None, format_type: str, suffix: str) -> str:
1322
+ # sdvplotR's indicator boxes round before scaling a percent (the pills scale first)
1323
+ if v is not None and digits is not None:
1324
+ v = round(v, digits)
1325
+ if v is not None and format_type == "percent":
1326
+ v *= 100
1327
+ return _format_value(v, digits, format_type, suffix)
1328
+
1329
+
1330
+ def gt_indicator_boxes(
1331
+ gt: GT,
1332
+ columns: Any = None,
1333
+ *,
1334
+ key_columns: Any = None,
1335
+ indicator_vals: Sequence[float] = (0, 1),
1336
+ indicator_rule: Callable[..., Any] | None = None,
1337
+ color_yes: str = "#FCCF10",
1338
+ color_no: str = "#EEEEEE",
1339
+ show_na_as_na: bool = False,
1340
+ show_text: bool = False,
1341
+ show_only: str | None = None,
1342
+ per_column_formats: dict[str, dict[str, Any]] | None = None,
1343
+ color_na: str | None = None,
1344
+ border_color: str | None = None,
1345
+ border_width: float = 0.25,
1346
+ box_width: float = 20,
1347
+ box_height: float = 20,
1348
+ text_size: float = 12,
1349
+ text_weight: str = "bold",
1350
+ ) -> GT:
1351
+ """Replace values with colored boxes: filled when a value meets a rule, neutral otherwise.
1352
+
1353
+ By default a box is filled when its value equals ``indicator_vals[1]``. Name the columns to convert with
1354
+ ``columns``, or the ones to leave alone with ``key_columns`` (not both); with neither, every body column is
1355
+ converted. Values are read as numbers, so text becomes missing. Converted columns are centered.
1356
+
1357
+ Args:
1358
+ gt: The table.
1359
+ columns: The columns to convert.
1360
+ key_columns: The columns to leave alone (every other body column is converted).
1361
+ indicator_vals: The ``(no, yes)`` values.
1362
+ indicator_rule: A function deciding when a box is filled, called with each cell's numeric value (and the
1363
+ column name, when it takes two arguments); ``None`` tests equality with ``indicator_vals[1]``.
1364
+ color_yes: Fill of boxes meeting the rule (hex; a translucent ``#rrggbbaa`` is drawn as given, and the text
1365
+ color is read on what it shows over the table background).
1366
+ color_no: Fill of the others (hex, as ``color_yes``).
1367
+ show_na_as_na: Print ``NA`` in a missing value's box instead of leaving it blank.
1368
+ show_text: Print the formatted value inside each box (boxes then widen to fit).
1369
+ show_only: Print text in only one class of box: ``"yes"``, ``"no"`` or ``"NA"``; ``None`` prints all.
1370
+ per_column_formats: ``{column: {"digits": ..., "format_type": ..., "suffix": ...}}``.
1371
+ color_na: Fill of missing values' boxes (hex); ``None`` uses ``color_no``.
1372
+ border_color: A border color around each box; ``None`` for none.
1373
+ border_width: The border width in pixels.
1374
+ box_width: Box width in pixels when ``show_text`` is off.
1375
+ box_height: Box height in pixels.
1376
+ text_size: Box text size in pixels.
1377
+ text_weight: Box text weight.
1378
+
1379
+ Returns:
1380
+ GT: A new table with the columns shown as boxes.
1381
+
1382
+ Raises:
1383
+ TypeError: ``gt`` is not a ``GT``.
1384
+ ValueError: Both ``columns`` and ``key_columns`` were given, no column is left to convert, or
1385
+ ``show_only`` is unknown.
1386
+
1387
+ Example:
1388
+ ::
1389
+
1390
+ from great_tables import GT
1391
+ import polars as pl
1392
+ from sdvplot.great_tables import gt_indicator_boxes
1393
+
1394
+ roster = pl.DataFrame({"player": ["A", "B", "C"], "starter": [True, False, True]})
1395
+
1396
+ gt_indicator_boxes(GT(roster), key_columns="player", show_text=True, border_color="#333333")
1397
+
1398
+ See Also:
1399
+ Ported from sdvplotR ``gt_indicator_boxes()``.
1400
+ """
1401
+ _check_gt(gt)
1402
+ if columns is not None and key_columns is not None:
1403
+ raise ValueError(
1404
+ "give either columns (the columns to convert to boxes) or key_columns (the ones to leave alone), not both"
1405
+ )
1406
+ if show_only not in (None, "yes", "no", "NA"):
1407
+ raise ValueError(f'show_only must be None, "yes", "no" or "NA", got {show_only!r}')
1408
+ if columns is not None:
1409
+ cols = _columns(gt, columns)
1410
+ else:
1411
+ keys = set(_columns(gt, key_columns)) if key_columns is not None else set()
1412
+ cols = [c for c in _columns(gt, None) if c not in keys]
1413
+ if not cols:
1414
+ raise ValueError("no columns left to convert to boxes")
1415
+ yes_value = indicator_vals[1]
1416
+ rule: Callable[..., Any] = indicator_rule if indicator_rule is not None else (lambda x: x == yes_value)
1417
+ with_column = _takes_column(rule)
1418
+ na_fill = color_na or color_no
1419
+ border = f"border: {border_width}px solid {border_color};" if border_color is not None else ""
1420
+ bg = _background(gt) # the colors go to CSS as given; the ink is measured on what a translucent one shows
1421
+
1422
+ out = gt
1423
+ for c in cols:
1424
+ spec = (per_column_formats or {}).get(c, {})
1425
+ digits, format_type, suffix = spec.get("digits"), spec.get("format_type", "number"), spec.get("suffix", "")
1426
+ numbers = [_number(v) for v in _values(gt, c)]
1427
+ labels = [_box_label(x, digits, format_type, suffix) for x in numbers]
1428
+ width = max((len(s) for s in labels), default=0) * 10 if show_text else box_width
1429
+ cells: dict[int, str] = {}
1430
+ for i, (x, text) in enumerate(zip(numbers, labels, strict=True)):
1431
+ if x is None:
1432
+ color = na_fill
1433
+ else:
1434
+ hit = rule(x, c) if with_column else rule(x)
1435
+ color = color_yes if _flags([hit])[0] else color_no
1436
+ if not show_text:
1437
+ content = ""
1438
+ elif show_only == "yes":
1439
+ content = text if color == color_yes else ""
1440
+ elif show_only == "no":
1441
+ content = text if color == color_no else ""
1442
+ elif show_only == "NA":
1443
+ content = "NA" if x is None and show_na_as_na else ""
1444
+ else:
1445
+ content = "" if x is None and not show_na_as_na else text
1446
+ cells[i] = (
1447
+ f"<span style='display:inline-block; width:{width}px; height:{box_height}px; "
1448
+ f"line-height:{box_height}px; background-color: {color}; color: {on_color(solid(color, bg))}; "
1449
+ f"vertical-align:middle; margin:4px 1px; font-size: {text_size}px; font-weight: {text_weight}; "
1450
+ f"text-align:center; {border}'>{content}</span>"
1451
+ )
1452
+ out = _fmt_rows(out, c, cells)
1453
+ return out.cols_align(align="center", columns=cols)
1454
+
1455
+
1456
+ # ---------------------------------------------------------------------------------------------------------------------
1457
+ # formatting, computed columns and stacked labels
1458
+
1459
+
1460
+ def _ordinal_suffix(n: float) -> str:
1461
+ n = abs(n)
1462
+ if n % 10 == 1 and n % 100 != 11:
1463
+ return "st"
1464
+ if n % 10 == 2 and n % 100 != 12:
1465
+ return "nd"
1466
+ if n % 10 == 3 and n % 100 != 13:
1467
+ return "rd"
1468
+ return "th"
1469
+
1470
+
1471
+ def gt_fmt_rank(gt: GT, columns: Any, superscript: bool = True, suffix_size: str = "0.7em") -> GT:
1472
+ """Format numbers as ordinals: 1 becomes 1st, 2 becomes 2nd, 23 becomes 23rd, 11-13 take "th".
1473
+
1474
+ Applied to the rendered cell text; a cell that does not read as a number is left alone.
1475
+
1476
+ Args:
1477
+ gt: The table.
1478
+ columns: The columns to format.
1479
+ superscript: Render the suffix as superscript.
1480
+ suffix_size: The suffix size, as a CSS size.
1481
+
1482
+ Returns:
1483
+ GT: A new table with ordinal formatting.
1484
+
1485
+ Raises:
1486
+ TypeError: ``gt`` is not a ``GT``.
1487
+
1488
+ Example:
1489
+ ::
1490
+
1491
+ from great_tables import GT
1492
+ import polars as pl
1493
+ from sdvplot.great_tables import gt_fmt_rank
1494
+
1495
+ standings = pl.DataFrame({"team": ["KC", "BUF", "BAL"], "place": [1, 2, 3]})
1496
+
1497
+ gt_fmt_rank(GT(standings), "place", superscript=False)
1498
+
1499
+ See Also:
1500
+ Ported from sdvplotR ``gt_fmt_rank()``.
1501
+ """
1502
+ _check_gt(gt)
1503
+
1504
+ def ordinal(text: str) -> str:
1505
+ n = _number(text)
1506
+ if n is None:
1507
+ return text
1508
+ shown, suffix = _natural(n), _ordinal_suffix(n)
1509
+ return f"{shown}<sup style='font-size:{suffix_size};'>{suffix}</sup>" if superscript else f"{shown}{suffix}"
1510
+
1511
+ return gt.text_transform(locations=loc.body(columns=columns), fn=ordinal)
1512
+
1513
+
1514
+ def gt_fmt_tally(
1515
+ gt: GT,
1516
+ columns: Any,
1517
+ *,
1518
+ separator: str = "-",
1519
+ label: str | None = None,
1520
+ share: bool = False,
1521
+ share_of: int | str = 0,
1522
+ share_location: str = "inline",
1523
+ share_decimals: int = 1,
1524
+ share_label: str = "%",
1525
+ share_prefix: str = " (",
1526
+ share_suffix: str = ")",
1527
+ **fmt_percent_kwargs: Any,
1528
+ ) -> GT:
1529
+ """Combine two or more count columns into one ``"32-5"`` cell, optionally with one count's share of the total.
1530
+
1531
+ The tally goes in the first column and the others are hidden (or the last one carries the share). A row with a
1532
+ missing count is left alone, and the share is blank where the counts sum to zero.
1533
+
1534
+ Args:
1535
+ gt: The table.
1536
+ columns: The count columns, in reading order (two or more).
1537
+ separator: Placed between the counts.
1538
+ label: A new label for the combined column; ``None`` keeps its label.
1539
+ share: Show one count as a share of the row total.
1540
+ share_of: The count the share is computed for: a 0-based position in ``columns`` or a column name.
1541
+ share_location: ``"inline"`` (appended to the tally) or ``"column"`` (the last count column carries it).
1542
+ share_decimals: Decimal places of the share.
1543
+ share_label: The share column's label when ``share_location="column"``.
1544
+ share_prefix: Placed before an inline share.
1545
+ share_suffix: Placed after an inline share.
1546
+ **fmt_percent_kwargs: Passed to great_tables ``vals.fmt_percent``.
1547
+
1548
+ Returns:
1549
+ GT: A new table with the counts combined.
1550
+
1551
+ Raises:
1552
+ TypeError: ``gt`` is not a ``GT``.
1553
+ ValueError: Fewer than two columns, ``share_of`` is not one of them, or ``share_location`` is unknown.
1554
+
1555
+ Example:
1556
+ ::
1557
+
1558
+ from great_tables import GT
1559
+ import polars as pl
1560
+ from sdvplot.great_tables import gt_fmt_tally
1561
+
1562
+ suites = pl.DataFrame({"suite": ["unit", "live"], "passed": [142, 30], "failed": [8, 2]})
1563
+ league = pl.DataFrame({"team": ["KC", "BUF"], "w": [12, 10], "d": [0, 1], "l": [5, 6]})
1564
+
1565
+ gt_fmt_tally(GT(suites), ["passed", "failed"], share=True) # "142-8 (94.7%)"
1566
+ gt_fmt_tally(GT(league), ["w", "d", "l"], label="W-D-L")
1567
+
1568
+ See Also:
1569
+ Ported from sdvplotR ``gt_fmt_tally()``.
1570
+ """
1571
+ _check_gt(gt)
1572
+ if share_location not in ("inline", "column"):
1573
+ raise ValueError(f'share_location must be "inline" or "column", got {share_location!r}')
1574
+ cols = _columns(gt, columns)
1575
+ if len(cols) < 2:
1576
+ raise ValueError("columns must select at least two columns")
1577
+ rows = list(zip(*([_number(v) for v in _values(gt, c)] for c in cols), strict=True))
1578
+ complete = [None if any(v is None for v in r) else cast(tuple[float, ...], r) for r in rows]
1579
+ tally = [None if r is None else separator.join(_natural(v) for v in r) for r in complete]
1580
+
1581
+ shares: list[str | None] = [None] * len(rows)
1582
+ if share:
1583
+ pos = cols.index(share_of) if isinstance(share_of, str) and share_of in cols else share_of
1584
+ if not isinstance(pos, int) or not 0 <= pos < len(cols):
1585
+ raise ValueError(f"share_of must name or index (from 0) one of {cols}")
1586
+ props = {i: r[pos] / sum(r) for i, r in enumerate(complete) if r is not None and sum(r) != 0}
1587
+ props = {i: p for i, p in props.items() if math.isfinite(p)}
1588
+ if props:
1589
+ text = cast(
1590
+ list[str], vals.fmt_percent(list(props.values()), decimals=share_decimals, **fmt_percent_kwargs)
1591
+ )
1592
+ for i, s in zip(props, text, strict=True):
1593
+ shares[i] = s
1594
+
1595
+ inline = share and share_location == "inline"
1596
+ display = [
1597
+ f"{t}{share_prefix}{s}{share_suffix}" if inline and t is not None and s is not None else t
1598
+ for t, s in zip(tally, shares, strict=True)
1599
+ ]
1600
+ out = _fmt_rows(gt, cols[0], {i: t for i, t in enumerate(display) if t is not None})
1601
+ if share and share_location == "column":
1602
+ carrier = cols[-1]
1603
+ out = _fmt_rows(out, carrier, {i: s for i, s in enumerate(shares) if s is not None})
1604
+ out = out.cols_label(cases={carrier: share_label})
1605
+ spent = cols[1:-1]
1606
+ else:
1607
+ spent = cols[1:]
1608
+ if spent:
1609
+ out = out.cols_hide(columns=spent)
1610
+ if label is not None:
1611
+ out = out.cols_label(cases={cols[0]: label})
1612
+ return out
1613
+
1614
+
1615
+ def _add_column(gt: GT, name: str, values: list[str], after: str) -> GT:
1616
+ """Add a text column placed after ``after``.
1617
+
1618
+ great_tables 1.0 has no ``cols_add``, so this extends the GT's data, empty body and boxhead (private
1619
+ attributes, pinned by a test). Formats, styles and labels on the other columns are kept.
1620
+ """
1621
+ frame = _frame(gt)
1622
+ column = nw.new_series(name, values, nw.String(), backend=nw.get_native_namespace(frame))
1623
+ data = frame.with_columns(column).to_native()
1624
+ boxhead = list(gt._boxhead)
1625
+ boxhead.insert([c.var for c in boxhead].index(after) + 1, ColInfo(name))
1626
+ return gt._replace(_tbl_data=data, _body=Body.from_empty(data), _boxhead=Boxhead(boxhead))
1627
+
1628
+
1629
+ def gt_delta(
1630
+ gt: GT,
1631
+ from_: Any,
1632
+ to: Any,
1633
+ *,
1634
+ column_label: str = "Change",
1635
+ percent: bool = False,
1636
+ decimals: int = 1,
1637
+ arrows: bool = False,
1638
+ color: bool = True,
1639
+ color_positive: str = "#1B7837",
1640
+ color_negative: str = "#B2182B",
1641
+ color_neutral: str | None = None,
1642
+ force_sign: bool = True,
1643
+ after: int | str | None = None,
1644
+ ) -> GT:
1645
+ """Add a column holding the change from one numeric column to another, signed and colored by direction.
1646
+
1647
+ The change is ``to - from_`` (or that over ``from_`` with ``percent=True``). A row is blank where either value
1648
+ is missing, or a percent change divides by zero. With ``arrows`` a triangle leads the magnitude in place of a
1649
+ sign.
1650
+
1651
+ Args:
1652
+ gt: The table.
1653
+ from_: The starting column (``from`` is a Python keyword).
1654
+ to: The ending column.
1655
+ column_label: The new column's label.
1656
+ percent: Show the change as a percent of ``from_``.
1657
+ decimals: Decimal places.
1658
+ arrows: Lead each value with an up or down triangle instead of a sign.
1659
+ color: Color the values by direction.
1660
+ color_positive: Color of an increase.
1661
+ color_negative: Color of a decrease.
1662
+ color_neutral: Color of no change; ``None`` leaves it the table's text color.
1663
+ force_sign: Show a plus on an increase (ignored with ``arrows``).
1664
+ after: The column the new one follows, a name or a 0-based position in the data; ``None`` places it
1665
+ after ``to``.
1666
+
1667
+ Returns:
1668
+ GT: A new table with the change column (right-aligned).
1669
+
1670
+ Raises:
1671
+ TypeError: ``gt`` is not a ``GT``.
1672
+ ValueError: ``from_`` or ``to`` does not select a single column, or ``after`` names no column.
1673
+
1674
+ Example:
1675
+ ::
1676
+
1677
+ from great_tables import GT
1678
+ import polars as pl
1679
+ from sdvplot.great_tables import gt_delta
1680
+
1681
+ revenue = pl.DataFrame({"team": ["KC", "BUF"], "q1": [100.0, 80.0], "q2": [120.0, 70.0]})
1682
+
1683
+ gt_delta(GT(revenue), "q1", "q2")
1684
+ gt_delta(GT(revenue), "q1", "q2", percent=True, arrows=True)
1685
+
1686
+ See Also:
1687
+ Ported from sdvplotR ``gt_delta()``.
1688
+ """
1689
+ _check_gt(gt)
1690
+ start, end = _columns(gt, from_), _columns(gt, to)
1691
+ if len(start) != 1 or len(end) != 1:
1692
+ raise ValueError("from_ and to must each select a single column")
1693
+ names = list(_frame(gt).columns)
1694
+ if after is None:
1695
+ anchor = end[0]
1696
+ elif isinstance(after, str) and after in names:
1697
+ anchor = after
1698
+ elif isinstance(after, int) and not isinstance(after, bool) and -len(names) <= after < len(names):
1699
+ anchor = names[after]
1700
+ else:
1701
+ raise ValueError(f"after must name or index (from 0) one of {names}, got {after!r}")
1702
+
1703
+ delta: list[float | None] = []
1704
+ for a, b in zip(_values(gt, start[0]), _values(gt, end[0]), strict=True):
1705
+ x, y = _number(a), _number(b)
1706
+ if x is None or y is None:
1707
+ d = None
1708
+ elif percent:
1709
+ d = (y - x) / x if x != 0 else None
1710
+ else:
1711
+ d = y - x
1712
+ delta.append(d if d is None or math.isfinite(d) else None)
1713
+
1714
+ present = [abs(d) if arrows else d for d in delta if d is not None]
1715
+ fmt = vals.fmt_percent if percent else vals.fmt_number
1716
+ texts = iter(cast(list[str], fmt(present, decimals=decimals, force_sign=force_sign and not arrows)))
1717
+ body = []
1718
+ for d in delta:
1719
+ if d is None:
1720
+ body.append("")
1721
+ continue
1722
+ text = next(texts)
1723
+ body.append(("▲ " if d > 0 else "▼ " if d < 0 else "") + text if arrows else text)
1724
+
1725
+ new, k = column_label, 0
1726
+ while new in names: # R's make.unique: Change, Change.1, Change.2, ...
1727
+ k += 1
1728
+ new = f"{column_label}.{k}"
1729
+ out = _add_column(gt, new, body, anchor)
1730
+ if new != column_label:
1731
+ out = out.cols_label(cases={new: column_label})
1732
+ out = out.cols_align(align="right", columns=new)
1733
+ if color:
1734
+ for rows, ink in (
1735
+ ([i for i, d in enumerate(delta) if d is not None and d > 0], color_positive),
1736
+ ([i for i, d in enumerate(delta) if d is not None and d < 0], color_negative),
1737
+ ([i for i, d in enumerate(delta) if d == 0], color_neutral),
1738
+ ):
1739
+ if rows and ink is not None:
1740
+ out = out.tab_style(style=style.text(color=ink), locations=loc.body(columns=new, rows=rows))
1741
+ return out
1742
+
1743
+
1744
+ def gt_column_subheaders(
1745
+ gt: GT,
1746
+ *,
1747
+ heading_color: str = "black",
1748
+ subtitle_color: str = "#808080",
1749
+ heading_weight: str = "bold",
1750
+ subtitle_weight: str = "normal",
1751
+ heading_size: float = 14,
1752
+ subtitle_size: float = 10,
1753
+ font: str | None = None,
1754
+ **subheaders: dict[str, str],
1755
+ ) -> GT:
1756
+ """Replace every column label with a two-line header: a heading over a smaller subtitle.
1757
+
1758
+ Every column is relabeled. A column not named in ``subheaders`` keeps its name as the heading and gets a
1759
+ non-breaking space as the subtitle, so the headers stay aligned. Call it after other label changes.
1760
+
1761
+ Args:
1762
+ gt: The table.
1763
+ heading_color: Heading text color.
1764
+ subtitle_color: Subtitle text color.
1765
+ heading_weight: Heading font weight.
1766
+ subtitle_weight: Subtitle font weight.
1767
+ heading_size: Heading size in pixels.
1768
+ subtitle_size: Subtitle size in pixels.
1769
+ font: A CSS font family for both lines (not imported: it must be installed or loaded by the theme).
1770
+ **subheaders: ``column={"heading": ..., "subtitle": ...}`` per column (either key may be left out). A
1771
+ column named like one of this function's arguments cannot be given this way.
1772
+
1773
+ Returns:
1774
+ GT: A new table with stacked labels.
1775
+
1776
+ Raises:
1777
+ TypeError: ``gt`` is not a ``GT``.
1778
+ ValueError: A ``subheaders`` key is not a column of the table.
1779
+
1780
+ Example:
1781
+ ::
1782
+
1783
+ from great_tables import GT
1784
+ import polars as pl
1785
+ from sdvplot.great_tables import gt_column_subheaders
1786
+
1787
+ df = pl.DataFrame(
1788
+ {"car": ["Mazda", "Datsun", "Hornet"], "mpg": [21.0, 22.8, 18.7], "hp": [110, 93, 175],
1789
+ "disp": [160.0, 108.0, 360.0]}
1790
+ )
1791
+
1792
+ gt_column_subheaders(GT(df), hp={"heading": "Horsepower", "subtitle": "HP"}, heading_color="blue")
1793
+
1794
+ See Also:
1795
+ Ported from sdvplotR ``gt_column_subheaders()``.
1796
+ """
1797
+ _check_gt(gt)
1798
+ names = list(_frame(gt).columns)
1799
+ unknown = sorted(set(subheaders) - set(names))
1800
+ if unknown:
1801
+ raise ValueError(f"subheaders name columns the table does not have: {unknown}")
1802
+ # the style attributes are single-quoted, so the family's quotes go in as &quot; (R's '{font}' ends them early)
1803
+ font_css = f"font-family: &quot;{escape(font)}&quot;;" if font is not None else ""
1804
+ labels: dict[str, Any] = {}
1805
+ for name in names:
1806
+ info = subheaders.get(name, {})
1807
+ labels[name] = html(
1808
+ "<div style='line-height: 1.05; margin-bottom: -2px;'>"
1809
+ f"<span style='font-size: {heading_size}px; font-weight: {heading_weight}; color: {heading_color}; "
1810
+ f"{font_css}'>{info.get('heading', name)}</span><br>"
1811
+ f"<span style='font-size: {subtitle_size}px; font-weight: {subtitle_weight}; color: {subtitle_color}; "
1812
+ f"{font_css}'>{info.get('subtitle', '&nbsp;')}</span></div>"
1813
+ )
1814
+ return gt.cols_label(cases=labels)