sdvplot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sdvplot/__init__.py +68 -0
- sdvplot/_cache.py +484 -0
- sdvplot/_colors.py +206 -0
- sdvplot/_contrast.py +60 -0
- sdvplot/_court.py +110 -0
- sdvplot/_deprecate.py +39 -0
- sdvplot/_dispatch.py +342 -0
- sdvplot/_errors.py +93 -0
- sdvplot/_headshots.py +131 -0
- sdvplot/_images.py +292 -0
- sdvplot/_index.py +178 -0
- sdvplot/_lazy.py +15 -0
- sdvplot/_manifest.py +73 -0
- sdvplot/_marks.py +296 -0
- sdvplot/_normalize.py +82 -0
- sdvplot/_placement.py +168 -0
- sdvplot/_resolve.py +363 -0
- sdvplot/_surface.py +220 -0
- sdvplot/_tables.py +87 -0
- sdvplot/_tiers.py +135 -0
- sdvplot/_types.py +38 -0
- sdvplot/_versions.py +30 -0
- sdvplot/_web.py +61 -0
- sdvplot/altair.py +652 -0
- sdvplot/bokeh.py +323 -0
- sdvplot/data/INDEX_VERSION +1 -0
- sdvplot/data/aliases.parquet +0 -0
- sdvplot/data/teams.parquet +0 -0
- sdvplot/folium.py +316 -0
- sdvplot/great_tables/__init__.py +267 -0
- sdvplot/great_tables/_cells.py +1814 -0
- sdvplot/great_tables/_export.py +844 -0
- sdvplot/great_tables/_layout.py +1802 -0
- sdvplot/great_tables/_marks.py +798 -0
- sdvplot/great_tables/_themes.py +2134 -0
- sdvplot/holoviews.py +303 -0
- sdvplot/matplotlib.py +934 -0
- sdvplot/plotly.py +595 -0
- sdvplot/plotnine.py +1104 -0
- sdvplot/plottable.py +141 -0
- sdvplot/py.typed +0 -0
- sdvplot/pygal.py +416 -0
- sdvplot/reactable.py +488 -0
- sdvplot/testing.py +579 -0
- sdvplot/typing.py +21 -0
- sdvplot-0.1.0.dist-info/METADATA +188 -0
- sdvplot-0.1.0.dist-info/RECORD +49 -0
- sdvplot-0.1.0.dist-info/WHEEL +4 -0
- sdvplot-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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("&", "&").replace("<", "<").replace(">", ">")
|
|
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 " (R's '{font}' ends them early)
|
|
1803
|
+
font_css = f"font-family: "{escape(font)}";" 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', ' ')}</span></div>"
|
|
1813
|
+
)
|
|
1814
|
+
return gt.cols_label(cases=labels)
|