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,844 @@
|
|
|
1
|
+
"""Image export and composition for great_tables: sdvplotR's gt_save_crop, gt_save_batch, gt_social_crop, gt_grid and
|
|
2
|
+
gt_stack_tables.
|
|
3
|
+
|
|
4
|
+
A table renders through great_tables' own ``GT.gtsave`` (headless Chrome over nokap, which great_tables 1.0 installs)
|
|
5
|
+
and composed HTML through ``nokap.from_html``, the counterparts of the ``gtExtras::gtsave_extra`` and
|
|
6
|
+
``webshot2::webshot`` calls sdvplotR makes. Trimming, padding, placing and resizing are Pillow ports of its magick
|
|
7
|
+
calls, checked against ImageMagick 6.9 in the tests.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import html
|
|
13
|
+
import math
|
|
14
|
+
import numbers
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
from collections import Counter
|
|
19
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
20
|
+
from os import PathLike
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import htmltools
|
|
25
|
+
import narwhals as nw
|
|
26
|
+
from great_tables import GT
|
|
27
|
+
from PIL import Image, ImageChops, ImageColor
|
|
28
|
+
|
|
29
|
+
from sdvplot._errors import warn
|
|
30
|
+
from sdvplot.great_tables._marks import _check_gt
|
|
31
|
+
|
|
32
|
+
_GRAVITY = ("center", "north", "south", "east", "west", "northwest", "northeast", "southwest", "southeast")
|
|
33
|
+
_JPEG_QUALITY = 92 # magick's default; Pillow's own (75) blurs table text
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------------------------------------------------
|
|
36
|
+
# Rendering (the only steps that need Chrome; tests replace these two functions)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _open_rgb(path: Path) -> Image.Image:
|
|
40
|
+
with Image.open(path) as im:
|
|
41
|
+
return im.convert("RGB")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _render_gt(gt: GT, zoom: float, expand: int) -> Image.Image:
|
|
45
|
+
"""Screenshot one table with great_tables' gtsave, as sdvplotR's gtsave_extra(zoom, expand)."""
|
|
46
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
47
|
+
path = Path(tmp) / "table.png"
|
|
48
|
+
gt.gtsave(path, zoom=zoom, expand=expand)
|
|
49
|
+
return _open_rgb(path)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _render_html(page: str, zoom: float) -> Image.Image:
|
|
53
|
+
"""Screenshot the element ``#sdvplot-page`` of an HTML page, as sdvplotR's webshot2::webshot(zoom=zoom)."""
|
|
54
|
+
import nokap # a great_tables dependency; imported here so the module loads without starting anything
|
|
55
|
+
|
|
56
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
57
|
+
path = Path(tmp) / "page.png"
|
|
58
|
+
nokap.from_html(page, path, selector="#sdvplot-page", zoom=zoom)
|
|
59
|
+
return _open_rgb(path)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------------------------------------------------
|
|
63
|
+
# Pillow ports of the magick calls
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _trim(img: Image.Image) -> Image.Image:
|
|
67
|
+
"""magick::image_trim(): crop uniform edges. As ImageMagick, the left and top edges match the top-left pixel, the
|
|
68
|
+
right edge the top-right pixel and the bottom edge the bottom-left pixel (fuzz 0). One color everywhere is
|
|
69
|
+
returned unchanged (magick raises there)."""
|
|
70
|
+
w, h = img.size
|
|
71
|
+
|
|
72
|
+
def box(xy: tuple[int, int]) -> tuple[int, int, int, int] | None:
|
|
73
|
+
return ImageChops.difference(img, Image.new(img.mode, img.size, img.getpixel(xy))).getbbox()
|
|
74
|
+
|
|
75
|
+
tl, tr, bl = box((0, 0)), box((w - 1, 0)), box((0, h - 1))
|
|
76
|
+
if tl is None or tr is None or bl is None or tr[2] <= tl[0] or bl[3] <= tl[1]:
|
|
77
|
+
return img
|
|
78
|
+
return img.crop((tl[0], tl[1], tr[2], bl[3]))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _extent(img: Image.Image, width: int, height: int, bg: str, gravity: str = "center") -> Image.Image:
|
|
82
|
+
"""magick::image_extent(): ``img`` on a ``width`` x ``height`` canvas of ``bg``, placed by ``gravity``."""
|
|
83
|
+
dx, dy = width - img.width, height - img.height
|
|
84
|
+
x = 0 if gravity.endswith("west") else dx if gravity.endswith("east") else dx // 2
|
|
85
|
+
y = 0 if gravity.startswith("north") else dy if gravity.startswith("south") else dy // 2
|
|
86
|
+
canvas = Image.new("RGB", (width, height), bg)
|
|
87
|
+
canvas.paste(img, (x, y))
|
|
88
|
+
return canvas
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _pad(img: Image.Image, bg: str, whitespace: int) -> Image.Image:
|
|
92
|
+
"""magick::image_border(bg, "{whitespace}x{whitespace}")."""
|
|
93
|
+
return _extent(img, img.width + 2 * whitespace, img.height + 2 * whitespace, bg)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _fit_width(img: Image.Image, width: int) -> Image.Image:
|
|
97
|
+
"""magick::image_resize("{width}x"): scale to ``width``, the height following (rounded half up, as ImageMagick)."""
|
|
98
|
+
height = max(1, math.floor(img.height * width / img.width + 0.5))
|
|
99
|
+
return img.resize((width, height), Image.Resampling.LANCZOS)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _canvas(width: int, height: int, ratio: float) -> tuple[int, int]:
|
|
103
|
+
"""gt_social_crop's canvas: grow the short side until width / height is ``ratio`` (R's round: half to even)."""
|
|
104
|
+
if width / height > ratio:
|
|
105
|
+
return width, round(width / ratio)
|
|
106
|
+
return round(height * ratio), height
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _ratio(aspect_ratio: str | float) -> float:
|
|
110
|
+
"""``"16:9"``, ``"4x5"`` or a number -> a positive finite ratio."""
|
|
111
|
+
ratio = math.nan
|
|
112
|
+
try:
|
|
113
|
+
if isinstance(aspect_ratio, str) and re.search("[:x]", aspect_ratio):
|
|
114
|
+
a, b = re.split("[:x]", aspect_ratio)
|
|
115
|
+
ratio = float(a) / float(b)
|
|
116
|
+
elif not isinstance(aspect_ratio, bool):
|
|
117
|
+
ratio = float(aspect_ratio)
|
|
118
|
+
except (TypeError, ValueError, ZeroDivisionError):
|
|
119
|
+
pass
|
|
120
|
+
if not (ratio > 0 and math.isfinite(ratio)):
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"aspect_ratio could not be read as a ratio: got {aspect_ratio!r}; use '1:1', '16:9', '4x5' or a number "
|
|
123
|
+
"like 1.91"
|
|
124
|
+
)
|
|
125
|
+
return ratio
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------------------------------------------------
|
|
129
|
+
# Argument checks: all run before anything renders, so a typo never costs a browser start
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _finite(value: Any) -> bool:
|
|
133
|
+
return isinstance(value, numbers.Real) and not isinstance(value, bool) and math.isfinite(value)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _pixels(name: str, value: Any, *, positive: bool = False) -> int:
|
|
137
|
+
if not _finite(value) or value < 0 or (positive and value == 0):
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"{name} must be a {'positive' if positive else 'non-negative'} number of pixels, got {value!r}"
|
|
140
|
+
)
|
|
141
|
+
return int(value)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _check_zoom(zoom: Any) -> None:
|
|
145
|
+
if not (_finite(zoom) and zoom > 0):
|
|
146
|
+
raise ValueError(f"zoom must be a positive number, got {zoom!r}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _check_file(file: str | PathLike[str] | None) -> None:
|
|
150
|
+
if file is None:
|
|
151
|
+
return
|
|
152
|
+
fmt = Image.registered_extensions().get(Path(file).suffix.lower())
|
|
153
|
+
if fmt is None or fmt not in Image.SAVE:
|
|
154
|
+
raise ValueError(f"file must end in an image extension such as .png, .jpg or .jpeg, got {str(file)!r}")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _check_common(bg: str, whitespace: Any, width: Any, file: str | PathLike[str] | None) -> tuple[int, int | None]:
|
|
158
|
+
ImageColor.getrgb(bg) # ValueError: unknown color specifier
|
|
159
|
+
_check_file(file)
|
|
160
|
+
return _pixels("whitespace", whitespace), None if width is None else _pixels("width", width, positive=True)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _check_gravity(gravity: str) -> str:
|
|
164
|
+
g = str(gravity).lower()
|
|
165
|
+
if g not in _GRAVITY:
|
|
166
|
+
raise ValueError(f"gravity must be one of {', '.join(_GRAVITY)}, got {gravity!r}")
|
|
167
|
+
return g
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _finish(img: Image.Image, file: str | PathLike[str] | None, width: int | None) -> Any:
|
|
171
|
+
if width is not None:
|
|
172
|
+
img = _fit_width(img, width)
|
|
173
|
+
if file is None:
|
|
174
|
+
return img
|
|
175
|
+
img.save(file, quality=_JPEG_QUALITY)
|
|
176
|
+
return file
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------------------------------------------------------
|
|
180
|
+
# Single tables
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def gt_save_crop(
|
|
184
|
+
data: GT,
|
|
185
|
+
file: str | PathLike[str] | None = None,
|
|
186
|
+
*,
|
|
187
|
+
bg: str = "white",
|
|
188
|
+
whitespace: int = 50,
|
|
189
|
+
zoom: float = 2,
|
|
190
|
+
expand: int = 5,
|
|
191
|
+
width: int | None = None,
|
|
192
|
+
) -> Any:
|
|
193
|
+
"""Save a table to an image, trimmed to its content with an even border.
|
|
194
|
+
|
|
195
|
+
Renders the table in headless Chrome (great_tables' ``GT.gtsave``), trims the page around it and pads a
|
|
196
|
+
``whitespace`` border of ``bg`` back on.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
data: The great_tables ``GT`` to save.
|
|
200
|
+
file: A path ending in an image extension Pillow writes (``.png``, ``.jpg``, ``.jpeg``, ...). ``None`` returns
|
|
201
|
+
the image instead of writing it.
|
|
202
|
+
bg: The border color: a CSS color name or hex code.
|
|
203
|
+
whitespace: The border, in pixels, left around the trimmed table.
|
|
204
|
+
zoom: The rendering zoom, a positive number; 2 gives a sharp (retina) image.
|
|
205
|
+
expand: Pixels of page captured around the table before trimming.
|
|
206
|
+
width: A final width in pixels, the height following, so a series of tables shares one width. ``None`` keeps
|
|
207
|
+
the rendered width.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
str | os.PathLike | PIL.Image.Image: ``file`` after writing it, or the image when ``file`` is ``None``.
|
|
211
|
+
|
|
212
|
+
Raises:
|
|
213
|
+
TypeError: If ``data`` is not a ``GT``.
|
|
214
|
+
ValueError: If ``bg``, ``whitespace``, ``width``, ``zoom`` or the extension of ``file`` is invalid (checked
|
|
215
|
+
before rendering).
|
|
216
|
+
nokap.ChromeNotFoundError: If no Chrome or Chromium is installed (set ``CHROME_PATH`` to point at one).
|
|
217
|
+
|
|
218
|
+
Example:
|
|
219
|
+
::
|
|
220
|
+
|
|
221
|
+
from great_tables import GT
|
|
222
|
+
from sdvplot.great_tables import gt_save_crop
|
|
223
|
+
import polars as pl
|
|
224
|
+
|
|
225
|
+
df = pl.DataFrame(
|
|
226
|
+
{
|
|
227
|
+
"team": ["KC", "BUF", "BAL"],
|
|
228
|
+
"espn_id": ["3139477", "3918298", "3916387"],
|
|
229
|
+
"wins": [12, 10, 9],
|
|
230
|
+
}
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
gt_save_crop(GT(df), "table.png", bg="#FBFAF7", width=900)
|
|
234
|
+
|
|
235
|
+
See Also:
|
|
236
|
+
gt_social_crop: the same, padded onto a fixed-ratio canvas.
|
|
237
|
+
Ported from sdvplotR ``gt_save_crop()``: https://sdvplotR.sportsdataverse.org/reference/gt_save_crop.html
|
|
238
|
+
"""
|
|
239
|
+
_check_gt(data, "data")
|
|
240
|
+
_check_zoom(zoom)
|
|
241
|
+
pad, final = _check_common(bg, whitespace, width, file)
|
|
242
|
+
return _finish(_pad(_trim(_render_gt(data, zoom, expand)), bg, pad), file, final)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def gt_social_crop(
|
|
246
|
+
data: GT,
|
|
247
|
+
file: str | PathLike[str] | None = None,
|
|
248
|
+
*,
|
|
249
|
+
aspect_ratio: str | float = "1:1",
|
|
250
|
+
bg: str = "white",
|
|
251
|
+
whitespace: int = 60,
|
|
252
|
+
gravity: str = "center",
|
|
253
|
+
zoom: float = 2,
|
|
254
|
+
expand: int = 5,
|
|
255
|
+
width: int | None = None,
|
|
256
|
+
) -> Any:
|
|
257
|
+
"""Save a table centered on a canvas of a fixed aspect ratio, for social posts.
|
|
258
|
+
|
|
259
|
+
The trimmed table is never cropped: the canvas' short side grows until the ratio is met.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
data: The great_tables ``GT`` to save.
|
|
263
|
+
file: A path ending in an image extension (``.png``, ``.jpg``, ...). ``None`` returns the image.
|
|
264
|
+
aspect_ratio: The canvas ratio, width to height: ``"1:1"``, ``"16:9"``, ``"4x5"`` or a number such as 1.91.
|
|
265
|
+
bg: The canvas color.
|
|
266
|
+
whitespace: Pixels left around the table before the canvas grows to the ratio.
|
|
267
|
+
gravity: Where the table sits on the canvas, as in magick: ``"center"``, ``"north"``, ``"south"``,
|
|
268
|
+
``"east"``, ``"west"``, ``"northwest"``, ``"northeast"``, ``"southwest"`` or ``"southeast"``.
|
|
269
|
+
zoom: The rendering zoom.
|
|
270
|
+
expand: Pixels of page captured around the table before trimming.
|
|
271
|
+
width: A final width in pixels for the finished canvas, the ratio held.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
str | os.PathLike | PIL.Image.Image: ``file`` after writing it, or the image when ``file`` is ``None``.
|
|
275
|
+
|
|
276
|
+
Raises:
|
|
277
|
+
TypeError: If ``data`` is not a ``GT``.
|
|
278
|
+
ValueError: If ``aspect_ratio`` is not a positive ratio, ``gravity`` is unknown, or ``bg``, ``whitespace``,
|
|
279
|
+
``width``, ``zoom`` or the extension of ``file`` is invalid (all checked before rendering).
|
|
280
|
+
nokap.ChromeNotFoundError: If no Chrome or Chromium is installed.
|
|
281
|
+
|
|
282
|
+
Example:
|
|
283
|
+
::
|
|
284
|
+
|
|
285
|
+
from great_tables import GT
|
|
286
|
+
from sdvplot.great_tables import gt_social_crop
|
|
287
|
+
import polars as pl
|
|
288
|
+
|
|
289
|
+
df = pl.DataFrame(
|
|
290
|
+
{
|
|
291
|
+
"team": ["KC", "BUF", "BAL"],
|
|
292
|
+
"espn_id": ["3139477", "3918298", "3916387"],
|
|
293
|
+
"wins": [12, 10, 9],
|
|
294
|
+
}
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
gt_social_crop(GT(df), "post.png", aspect_ratio="4:5", bg="#0C0D10")
|
|
298
|
+
|
|
299
|
+
See Also:
|
|
300
|
+
gt_save_crop: a plain trimmed save.
|
|
301
|
+
Ported from sdvplotR ``gt_social_crop()``: https://sdvplotR.sportsdataverse.org/reference/gt_social_crop.html
|
|
302
|
+
"""
|
|
303
|
+
_check_gt(data, "data")
|
|
304
|
+
ratio = _ratio(aspect_ratio)
|
|
305
|
+
place = _check_gravity(gravity)
|
|
306
|
+
_check_zoom(zoom)
|
|
307
|
+
pad, final = _check_common(bg, whitespace, width, file)
|
|
308
|
+
img = _pad(_trim(_render_gt(data, zoom, expand)), bg, pad)
|
|
309
|
+
img = _extent(img, *_canvas(img.width, img.height, ratio), bg, place)
|
|
310
|
+
return _finish(img, file, final)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ---------------------------------------------------------------------------------------------------------------------
|
|
314
|
+
# Batches
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _slug(value: Any) -> str:
|
|
318
|
+
return re.sub(r"[^A-Za-z0-9._-]+", "-", str(value)).strip("-").lower()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def gt_save_batch(
|
|
322
|
+
data: Any,
|
|
323
|
+
group: str,
|
|
324
|
+
fn: Callable[[Any, Any], GT],
|
|
325
|
+
file: str,
|
|
326
|
+
*,
|
|
327
|
+
dir: str | PathLike[str],
|
|
328
|
+
match_width: bool = True,
|
|
329
|
+
bg: str = "white",
|
|
330
|
+
whitespace: int = 50,
|
|
331
|
+
zoom: float = 2,
|
|
332
|
+
quiet: bool = False,
|
|
333
|
+
) -> list[str]:
|
|
334
|
+
"""Save a matched set of table images, one per group.
|
|
335
|
+
|
|
336
|
+
Splits ``data`` by a column, builds a table per group with ``fn`` and writes one image per group, padded to a
|
|
337
|
+
common width so a posted series is not ragged. A group whose table fails to build or render is skipped and named
|
|
338
|
+
in one warning at the end; the rest are still written.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
data: A pandas or polars DataFrame (any narwhals-supported eager frame).
|
|
342
|
+
group: The column to split on. Its missing values are skipped.
|
|
343
|
+
fn: Builds one table, called as ``fn(df, value)`` with the group's rows (the same frame type as ``data``) and
|
|
344
|
+
its value; it must return a ``GT``.
|
|
345
|
+
file: A file name containing ``{group}``, replaced by the group value with anything awkward turned into a
|
|
346
|
+
dash and lower-cased, so ``"North / East"`` writes ``"net-north-east.png"`` for ``"net-{group}.png"``.
|
|
347
|
+
dir: The directory to write into, created when missing. It has no default, so a batch never lands in the
|
|
348
|
+
working directory unasked; pass ``"."`` for that.
|
|
349
|
+
match_width: Pad every image to the widest one's width.
|
|
350
|
+
bg: The padding color.
|
|
351
|
+
whitespace: The border, in pixels, around each table.
|
|
352
|
+
zoom: The rendering zoom.
|
|
353
|
+
quiet: Do not print the per-group progress lines (to stderr).
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
list[str]: The files written, in group order.
|
|
357
|
+
|
|
358
|
+
Raises:
|
|
359
|
+
TypeError: If ``data`` is not a data frame or ``fn`` is not callable.
|
|
360
|
+
ValueError: If ``group`` is not a column, has no non-missing values, two values would write the same file,
|
|
361
|
+
``file`` lacks ``{group}`` or an image extension, or ``bg``, ``whitespace`` or ``zoom`` is invalid (all
|
|
362
|
+
before rendering).
|
|
363
|
+
RuntimeError: If no group built.
|
|
364
|
+
nokap.ChromeNotFoundError: If no browser can start (raised at the first group, not collected per group).
|
|
365
|
+
|
|
366
|
+
Example:
|
|
367
|
+
::
|
|
368
|
+
|
|
369
|
+
from great_tables import GT
|
|
370
|
+
from sdvplot.great_tables import gt_save_batch
|
|
371
|
+
import polars as pl
|
|
372
|
+
|
|
373
|
+
cars = pl.DataFrame({"cyl": [4, 4, 6], "mpg": [22.8, 24.4, 21.0]})
|
|
374
|
+
|
|
375
|
+
def build(df, value):
|
|
376
|
+
return GT(df).tab_header(title=f"{value} cylinders")
|
|
377
|
+
|
|
378
|
+
gt_save_batch(cars, "cyl", build, "cars-{group}.png", dir="out")
|
|
379
|
+
|
|
380
|
+
See Also:
|
|
381
|
+
gt_grid: the same split composed into one image.
|
|
382
|
+
Ported from sdvplotR ``gt_save_batch()``: https://sdvplotR.sportsdataverse.org/reference/gt_save_batch.html
|
|
383
|
+
"""
|
|
384
|
+
from nokap import ChromeNotFoundError, ChromeStartError
|
|
385
|
+
|
|
386
|
+
frame = nw.from_native(data, eager_only=True)
|
|
387
|
+
if not callable(fn):
|
|
388
|
+
raise TypeError("fn must be a function returning a GT")
|
|
389
|
+
if "{group}" not in file:
|
|
390
|
+
raise ValueError(f"file must contain {{group}}, as in 'net-{{group}}.png', got {file!r}")
|
|
391
|
+
if not isinstance(group, str) or group not in frame.columns:
|
|
392
|
+
raise ValueError(f"group must name one column of data, got {group!r}")
|
|
393
|
+
pad, _ = _check_common(bg, whitespace, None, file.replace("{group}", "x"))
|
|
394
|
+
_check_zoom(zoom)
|
|
395
|
+
keys = frame[group].drop_nulls().unique(maintain_order=True).to_list()
|
|
396
|
+
if not keys:
|
|
397
|
+
raise ValueError(f"group {group!r} has no non-missing values")
|
|
398
|
+
names = [file.replace("{group}", _slug(k)) for k in keys]
|
|
399
|
+
shared = sorted(n for n, c in Counter(names).items() if c > 1)
|
|
400
|
+
if shared:
|
|
401
|
+
raise ValueError(f"group values write to the same file name: {', '.join(shared)}; rename the values first")
|
|
402
|
+
|
|
403
|
+
built: list[tuple[str, Image.Image]] = []
|
|
404
|
+
failed: list[str] = []
|
|
405
|
+
for key, name in zip(keys, names, strict=True):
|
|
406
|
+
if not quiet:
|
|
407
|
+
print(f"Building {key!r}", file=sys.stderr)
|
|
408
|
+
try:
|
|
409
|
+
tbl = fn(frame.filter(nw.col(group) == key).to_native(), key)
|
|
410
|
+
_check_gt(tbl, "fn's return value")
|
|
411
|
+
built.append((name, _trim(_render_gt(tbl, zoom, 5))))
|
|
412
|
+
except (ChromeNotFoundError, ChromeStartError):
|
|
413
|
+
raise # no browser: every group would fail the same way
|
|
414
|
+
except Exception as e:
|
|
415
|
+
failed.append(f"{key}: {e}")
|
|
416
|
+
if not built:
|
|
417
|
+
raise RuntimeError("No group built successfully:\n" + "\n".join(failed))
|
|
418
|
+
|
|
419
|
+
widest = max(img.width for _, img in built) if match_width else None
|
|
420
|
+
Path(dir).mkdir(parents=True, exist_ok=True)
|
|
421
|
+
paths = []
|
|
422
|
+
for name, img in built:
|
|
423
|
+
if widest is not None:
|
|
424
|
+
img = _extent(img, widest, img.height, bg)
|
|
425
|
+
path = str(Path(dir) / name)
|
|
426
|
+
_pad(img, bg, pad).save(path, quality=_JPEG_QUALITY)
|
|
427
|
+
paths.append(path)
|
|
428
|
+
if failed:
|
|
429
|
+
warn(f"{len(failed)} group(s) failed and were skipped:\n" + "\n".join(failed))
|
|
430
|
+
if not quiet:
|
|
431
|
+
print(f"Wrote {len(paths)} file(s) to {dir}", file=sys.stderr)
|
|
432
|
+
return paths
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
# ---------------------------------------------------------------------------------------------------------------------
|
|
436
|
+
# Composition (sdvplotR's utils-style.R *_style lists and the shared header/footer)
|
|
437
|
+
|
|
438
|
+
_STYLE_KEYS = (
|
|
439
|
+
"font",
|
|
440
|
+
"size",
|
|
441
|
+
"color",
|
|
442
|
+
"weight",
|
|
443
|
+
"italic",
|
|
444
|
+
"spacing",
|
|
445
|
+
"transform",
|
|
446
|
+
"align",
|
|
447
|
+
"line_height",
|
|
448
|
+
"margin_top",
|
|
449
|
+
"margin_bottom",
|
|
450
|
+
"padding_top",
|
|
451
|
+
"padding_bottom",
|
|
452
|
+
)
|
|
453
|
+
_CSS_PROPS = (
|
|
454
|
+
("size", "font-size"),
|
|
455
|
+
("color", "color"),
|
|
456
|
+
("weight", "font-weight"),
|
|
457
|
+
("spacing", "letter-spacing"),
|
|
458
|
+
("transform", "text-transform"),
|
|
459
|
+
("align", "text-align"),
|
|
460
|
+
("line_height", "line-height"),
|
|
461
|
+
("margin_top", "margin-top"),
|
|
462
|
+
("margin_bottom", "margin-bottom"),
|
|
463
|
+
("padding_top", "padding-top"),
|
|
464
|
+
("padding_bottom", "padding-bottom"),
|
|
465
|
+
)
|
|
466
|
+
_LENGTHS = {"size", "spacing", "margin_top", "margin_bottom", "padding_top", "padding_bottom"}
|
|
467
|
+
_FONT_FALLBACK = "system-ui, -apple-system, sans-serif"
|
|
468
|
+
_STYLE_DEFAULTS: dict[str, dict[str, Any]] = {
|
|
469
|
+
"title": {"size": "28px", "weight": 700, "color": "#111111", "align": "center", "margin_bottom": 4},
|
|
470
|
+
"subtitle": {"size": "16px", "weight": 400, "color": "#666666", "align": "center", "margin_bottom": 12},
|
|
471
|
+
"caption": {"size": "12px", "weight": 400, "color": "#8A8A8A", "align": "center", "margin_top": 10},
|
|
472
|
+
"source_note": {"size": "12px", "weight": 400, "color": "#8A8A8A", "align": "right", "margin_top": 6},
|
|
473
|
+
"label": {"size": "12px", "weight": 600, "color": "#555555", "align": "left", "margin_bottom": 6},
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _style(arg: str, user: Mapping[str, Any] | None, default: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
478
|
+
"""A style dict (sdvplotR's ``*_style`` lists): ``default`` with the caller's keys (argument ``arg``) on top."""
|
|
479
|
+
user = dict(user or {})
|
|
480
|
+
unknown = sorted(set(user) - set(_STYLE_KEYS))
|
|
481
|
+
if unknown:
|
|
482
|
+
raise ValueError(f"{arg} has unknown key(s) {unknown}; recognized: {', '.join(_STYLE_KEYS)}")
|
|
483
|
+
return {**(default or {}), **user}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _css_len(value: Any) -> str:
|
|
487
|
+
"""A number (numpy numbers included) reads as pixels; anything else is a CSS length already."""
|
|
488
|
+
return f"{value}px" if isinstance(value, numbers.Real) and not isinstance(value, bool) else str(value)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _style_css(style: Mapping[str, Any], font_fallback: str | None = None) -> str:
|
|
492
|
+
"""A style dict -> inline CSS, in sdvplotR's .style_css() order.
|
|
493
|
+
|
|
494
|
+
With ``font_fallback`` the font family is always set (composed HTML, which has no table font to inherit); without
|
|
495
|
+
it a style with no font sets none, so the text inherits the theme's (the wave C2 headers and legends).
|
|
496
|
+
"""
|
|
497
|
+
font = style.get("font")
|
|
498
|
+
out = []
|
|
499
|
+
if font is not None:
|
|
500
|
+
out.append(f"font-family:'{font}', {font_fallback or 'sans-serif'};")
|
|
501
|
+
elif font_fallback is not None:
|
|
502
|
+
out.append(f"font-family:{font_fallback};")
|
|
503
|
+
for key, prop in _CSS_PROPS:
|
|
504
|
+
value = style.get(key)
|
|
505
|
+
if value is not None:
|
|
506
|
+
out.append(f"{prop}:{_css_len(value) if key in _LENGTHS else value};")
|
|
507
|
+
if key == "weight" and style.get("italic") is True:
|
|
508
|
+
out.append("font-style:italic;")
|
|
509
|
+
return "".join(out)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _text(value: Any) -> htmltools.HTML:
|
|
513
|
+
"""A title, caption or label: md() and html() text render as markup; anything else is escaped, as great_tables
|
|
514
|
+
does (sdvplotR inserts plain strings as HTML)."""
|
|
515
|
+
return htmltools.HTML(value.to_html() if hasattr(value, "to_html") else html.escape(str(value)))
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _fonts(*styles: Mapping[str, Any]) -> list[str]:
|
|
519
|
+
"""The distinct Google font names across style dicts, in order."""
|
|
520
|
+
return list(dict.fromkeys(s["font"] for s in styles if s.get("font") is not None))
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _font_link(styles: Sequence[Mapping[str, Any]]) -> htmltools.Tag | None:
|
|
524
|
+
"""composed HTML never runs through great_tables' google_font(), so a named font is fetched here."""
|
|
525
|
+
fonts = _fonts(*styles)
|
|
526
|
+
if not fonts:
|
|
527
|
+
return None
|
|
528
|
+
families = "&".join(f"family={f.replace(' ', '+')}:wght@100..900" for f in fonts)
|
|
529
|
+
return htmltools.tags.link(rel="stylesheet", href=f"https://fonts.googleapis.com/css2?{families}&display=swap")
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _tables(tables: Any) -> list[GT]:
|
|
533
|
+
if isinstance(tables, GT):
|
|
534
|
+
raise TypeError("tables must be a list of GT objects; wrap a single table as [gt]")
|
|
535
|
+
items = [] if tables is None else list(tables.values()) if isinstance(tables, Mapping) else list(tables)
|
|
536
|
+
if not items:
|
|
537
|
+
raise ValueError("tables must be a non-empty list of GT objects")
|
|
538
|
+
bad = [i for i, t in enumerate(items) if not isinstance(t, GT)]
|
|
539
|
+
if bad:
|
|
540
|
+
raise TypeError(f"tables must contain only GT objects; item(s) {bad} are not")
|
|
541
|
+
return items
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _table_html(gt: GT) -> htmltools.Tag:
|
|
545
|
+
return htmltools.div(htmltools.HTML(gt.as_raw_html()))
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _compose(
|
|
549
|
+
body: htmltools.Tag,
|
|
550
|
+
*,
|
|
551
|
+
title: Any,
|
|
552
|
+
subtitle: Any,
|
|
553
|
+
caption: Any,
|
|
554
|
+
source_note: Any,
|
|
555
|
+
caption_rule: bool,
|
|
556
|
+
title_style: Mapping[str, Any] | None,
|
|
557
|
+
subtitle_style: Mapping[str, Any] | None,
|
|
558
|
+
caption_style: Mapping[str, Any] | None,
|
|
559
|
+
source_note_style: Mapping[str, Any] | None,
|
|
560
|
+
more_styles: Sequence[Mapping[str, Any]] = (),
|
|
561
|
+
) -> htmltools.Tag:
|
|
562
|
+
"""The shared heading and footer around a grid or stack, in a shrink-to-fit wrapper (sdvplotR's layout)."""
|
|
563
|
+
s_title = _style("title_style", title_style, _STYLE_DEFAULTS["title"])
|
|
564
|
+
s_subtitle = _style("subtitle_style", subtitle_style, _STYLE_DEFAULTS["subtitle"])
|
|
565
|
+
s_caption = _style("caption_style", caption_style, _STYLE_DEFAULTS["caption"])
|
|
566
|
+
s_source = _style("source_note_style", source_note_style, _STYLE_DEFAULTS["source_note"])
|
|
567
|
+
# with no subtitle the title carries the gap the subtitle would have held
|
|
568
|
+
if subtitle is None and "margin_bottom" not in (title_style or {}):
|
|
569
|
+
s_title["margin_bottom"] = _STYLE_DEFAULTS["subtitle"]["margin_bottom"]
|
|
570
|
+
if caption_rule and "padding_bottom" not in (caption_style or {}):
|
|
571
|
+
s_caption["padding_bottom"] = 6
|
|
572
|
+
link = _font_link([s_title, s_subtitle, s_caption, s_source, *more_styles])
|
|
573
|
+
|
|
574
|
+
has_header = title is not None or subtitle is not None
|
|
575
|
+
has_footer = caption is not None or source_note is not None
|
|
576
|
+
if not (has_header or has_footer):
|
|
577
|
+
return htmltools.div(link, body) if link is not None else body
|
|
578
|
+
rule = f"border-bottom:1px solid {s_caption['color']};" if caption_rule else ""
|
|
579
|
+
header = footer = None
|
|
580
|
+
if has_header:
|
|
581
|
+
header = htmltools.div(
|
|
582
|
+
htmltools.div(_text(title), style=_style_css(s_title, _FONT_FALLBACK)) if title is not None else None,
|
|
583
|
+
htmltools.div(_text(subtitle), style=_style_css(s_subtitle, _FONT_FALLBACK))
|
|
584
|
+
if subtitle is not None
|
|
585
|
+
else None,
|
|
586
|
+
)
|
|
587
|
+
if has_footer:
|
|
588
|
+
footer = htmltools.div(
|
|
589
|
+
htmltools.div(_text(caption), style=_style_css(s_caption, _FONT_FALLBACK) + rule)
|
|
590
|
+
if caption is not None
|
|
591
|
+
else None,
|
|
592
|
+
htmltools.div(_text(source_note), style=_style_css(s_source, _FONT_FALLBACK))
|
|
593
|
+
if source_note is not None
|
|
594
|
+
else None,
|
|
595
|
+
)
|
|
596
|
+
# inner wrapper shrinks to the tables, outer one recenters it; "safe" centering plus overflow-x keeps a sheet
|
|
597
|
+
# wider than a phone scrollable
|
|
598
|
+
return htmltools.div(
|
|
599
|
+
link,
|
|
600
|
+
htmltools.div(header, body, footer, style="display: inline-block;"),
|
|
601
|
+
style="display: flex; justify-content: center; justify-content: safe center; overflow-x: auto;",
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _save_composed(
|
|
606
|
+
composed: htmltools.Tag, file: str | PathLike[str], bg: str, whitespace: int, zoom: float
|
|
607
|
+
) -> str | PathLike[str]:
|
|
608
|
+
# the wrapper, not the page, is captured: it is bg on every edge, so trimming is even on all four sides
|
|
609
|
+
page = htmltools.div(
|
|
610
|
+
composed, id="sdvplot-page", style=f"display: inline-block; padding: 8px; background-color: {bg};"
|
|
611
|
+
)
|
|
612
|
+
doc = f'<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>{page}</body></html>'
|
|
613
|
+
_pad(_trim(_render_html(doc, zoom)), bg, whitespace).save(file, quality=_JPEG_QUALITY)
|
|
614
|
+
return file
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def gt_grid(
|
|
618
|
+
tables: Sequence[GT] | Mapping[Any, GT] | None = None,
|
|
619
|
+
*,
|
|
620
|
+
ncol: int = 2,
|
|
621
|
+
labels: Any = None,
|
|
622
|
+
label_style: Mapping[str, Any] | None = None,
|
|
623
|
+
title: Any = None,
|
|
624
|
+
subtitle: Any = None,
|
|
625
|
+
caption: Any = None,
|
|
626
|
+
source_note: Any = None,
|
|
627
|
+
caption_rule: bool = False,
|
|
628
|
+
title_style: Mapping[str, Any] | None = None,
|
|
629
|
+
subtitle_style: Mapping[str, Any] | None = None,
|
|
630
|
+
caption_style: Mapping[str, Any] | None = None,
|
|
631
|
+
source_note_style: Mapping[str, Any] | None = None,
|
|
632
|
+
gap: float = 24,
|
|
633
|
+
align: str = "top",
|
|
634
|
+
file: str | PathLike[str] | None = None,
|
|
635
|
+
bg: str = "white",
|
|
636
|
+
whitespace: int = 50,
|
|
637
|
+
zoom: float = 2,
|
|
638
|
+
) -> Any:
|
|
639
|
+
"""Arrange several tables in a grid of rows and columns, as small multiples.
|
|
640
|
+
|
|
641
|
+
The grid is HTML, not a ``GT``: each table keeps its own columns and header, so this is a last step after every
|
|
642
|
+
table is themed. Give ``file`` to write it straight to an image.
|
|
643
|
+
|
|
644
|
+
Args:
|
|
645
|
+
tables: A list of ``GT`` objects (a dict's values are used in order, so ``gt_theme_preview()``'s dict works).
|
|
646
|
+
ncol: The number of tables across.
|
|
647
|
+
labels: A caption above each table, recycled across ``tables``: a string, ``md()``/``html()`` text, or a
|
|
648
|
+
non-empty list of them. Plain strings are escaped; use ``html()`` for markup.
|
|
649
|
+
label_style: Style for the labels (see ``title_style``).
|
|
650
|
+
title: A heading above the whole grid: a string, ``md()`` or ``html()``. Plain strings are escaped (as in
|
|
651
|
+
great_tables); use ``html()`` for markup. ``subtitle``, ``caption`` and ``source_note`` take the same.
|
|
652
|
+
subtitle: A line below ``title``.
|
|
653
|
+
caption: A note below the grid.
|
|
654
|
+
source_note: A second line below ``caption``, right-aligned by default.
|
|
655
|
+
caption_rule: Draw a hairline between ``caption`` and ``source_note``.
|
|
656
|
+
title_style: A dict of any of ``font`` (a Google font name), ``size``, ``color``, ``weight``, ``italic``,
|
|
657
|
+
``spacing``, ``transform``, ``align``, ``line_height``, ``margin_top``, ``margin_bottom``,
|
|
658
|
+
``padding_top``, ``padding_bottom``. Lengths take a number (pixels) or a CSS string; keys left out keep
|
|
659
|
+
their defaults.
|
|
660
|
+
subtitle_style: As ``title_style``, for the subtitle.
|
|
661
|
+
caption_style: As ``title_style``, for the caption.
|
|
662
|
+
source_note_style: As ``title_style``, for the source note.
|
|
663
|
+
gap: The space between tables, a non-negative number of pixels.
|
|
664
|
+
align: How tables of differing height line up in a row: ``"top"``, ``"center"`` or ``"bottom"``.
|
|
665
|
+
file: A path to write an image to; ``None`` returns the HTML.
|
|
666
|
+
bg: The background color when saving.
|
|
667
|
+
whitespace: Padding, in pixels, around the grid when saving.
|
|
668
|
+
zoom: The rendering zoom when saving.
|
|
669
|
+
|
|
670
|
+
Returns:
|
|
671
|
+
htmltools.Tag | str | os.PathLike: The grid as HTML (it displays in a notebook), or ``file`` after writing it.
|
|
672
|
+
|
|
673
|
+
Raises:
|
|
674
|
+
TypeError: If ``tables`` holds anything but ``GT`` objects.
|
|
675
|
+
TypeError: If ``labels`` is neither text nor a list of it.
|
|
676
|
+
ValueError: If ``tables`` or ``labels`` is empty, ``ncol`` is below 1, ``gap`` is not a non-negative number,
|
|
677
|
+
``zoom`` is not a positive number, ``align`` or a style key is unknown, or (when saving) ``bg``,
|
|
678
|
+
``whitespace`` or the extension of ``file`` is invalid.
|
|
679
|
+
nokap.ChromeNotFoundError: If saving and no Chrome or Chromium is installed.
|
|
680
|
+
|
|
681
|
+
Example:
|
|
682
|
+
::
|
|
683
|
+
|
|
684
|
+
import polars as pl
|
|
685
|
+
from great_tables import GT
|
|
686
|
+
from sdvplot.great_tables import gt_grid
|
|
687
|
+
|
|
688
|
+
east = GT(pl.DataFrame({"team": ["BUF", "MIA"], "wins": [11, 9]}))
|
|
689
|
+
west = GT(pl.DataFrame({"team": ["KC", "LV"], "wins": [12, 8]}))
|
|
690
|
+
north = GT(pl.DataFrame({"team": ["BAL", "CIN"], "wins": [10, 9]}))
|
|
691
|
+
south = GT(pl.DataFrame({"team": ["HOU", "IND"], "wins": [10, 8]}))
|
|
692
|
+
|
|
693
|
+
gt_grid([east, west, north, south], ncol=2, title="Division leaders", caption="Data: ESPN")
|
|
694
|
+
gt_grid([east, west], file="divisions.png", bg="#FBFAF7")
|
|
695
|
+
|
|
696
|
+
See Also:
|
|
697
|
+
gt_stack_tables: a vertical stack.
|
|
698
|
+
Ported from sdvplotR ``gt_grid()``: https://sdvplotR.sportsdataverse.org/reference/gt_grid.html
|
|
699
|
+
"""
|
|
700
|
+
items = _tables(tables)
|
|
701
|
+
if isinstance(ncol, bool) or not isinstance(ncol, numbers.Integral) or ncol < 1:
|
|
702
|
+
raise ValueError(f"ncol must be an integer of at least 1, got {ncol!r}")
|
|
703
|
+
places = {"top": "start", "center": "center", "bottom": "end"}
|
|
704
|
+
if align not in places:
|
|
705
|
+
raise ValueError(f"align must be one of {', '.join(places)}, got {align!r}")
|
|
706
|
+
_pixels("gap", gap)
|
|
707
|
+
_check_zoom(zoom)
|
|
708
|
+
s_label = _style("label_style", label_style, _STYLE_DEFAULTS["label"])
|
|
709
|
+
pad = 0 if file is None else _check_common(bg, whitespace, None, file)[0]
|
|
710
|
+
if labels is not None:
|
|
711
|
+
if isinstance(labels, str) or hasattr(labels, "to_html"):
|
|
712
|
+
labs = [labels]
|
|
713
|
+
elif isinstance(labels, Iterable):
|
|
714
|
+
labs = list(labels)
|
|
715
|
+
else:
|
|
716
|
+
raise TypeError(
|
|
717
|
+
f"labels must be a string, md() or html() text, or a list of them, not {type(labels).__name__}"
|
|
718
|
+
)
|
|
719
|
+
if not labs:
|
|
720
|
+
raise ValueError("labels must be non-empty; pass None for no labels")
|
|
721
|
+
|
|
722
|
+
if labels is None:
|
|
723
|
+
cells = [_table_html(t) for t in items]
|
|
724
|
+
else:
|
|
725
|
+
cells = [
|
|
726
|
+
htmltools.div(
|
|
727
|
+
htmltools.div(_text(labs[i % len(labs)]), style=_style_css(s_label, _FONT_FALLBACK)), _table_html(t)
|
|
728
|
+
)
|
|
729
|
+
for i, t in enumerate(items)
|
|
730
|
+
]
|
|
731
|
+
grid = htmltools.div(
|
|
732
|
+
*cells,
|
|
733
|
+
style=f"display: grid; grid-template-columns: repeat({ncol}, max-content); gap: {gap}px; "
|
|
734
|
+
f"align-items: {places[align]}; justify-content: center;",
|
|
735
|
+
)
|
|
736
|
+
composed = _compose(
|
|
737
|
+
grid,
|
|
738
|
+
title=title,
|
|
739
|
+
subtitle=subtitle,
|
|
740
|
+
caption=caption,
|
|
741
|
+
source_note=source_note,
|
|
742
|
+
caption_rule=caption_rule,
|
|
743
|
+
title_style=title_style,
|
|
744
|
+
subtitle_style=subtitle_style,
|
|
745
|
+
caption_style=caption_style,
|
|
746
|
+
source_note_style=source_note_style,
|
|
747
|
+
more_styles=[s_label] if labels is not None else [],
|
|
748
|
+
)
|
|
749
|
+
return composed if file is None else _save_composed(composed, file, bg, pad, zoom)
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def gt_stack_tables(
|
|
753
|
+
tables: Sequence[GT] | Mapping[Any, GT] | None = None,
|
|
754
|
+
*,
|
|
755
|
+
gap: float = 16,
|
|
756
|
+
align: str = "center",
|
|
757
|
+
title: Any = None,
|
|
758
|
+
subtitle: Any = None,
|
|
759
|
+
caption: Any = None,
|
|
760
|
+
source_note: Any = None,
|
|
761
|
+
caption_rule: bool = False,
|
|
762
|
+
title_style: Mapping[str, Any] | None = None,
|
|
763
|
+
subtitle_style: Mapping[str, Any] | None = None,
|
|
764
|
+
caption_style: Mapping[str, Any] | None = None,
|
|
765
|
+
source_note_style: Mapping[str, Any] | None = None,
|
|
766
|
+
file: str | PathLike[str] | None = None,
|
|
767
|
+
bg: str = "white",
|
|
768
|
+
whitespace: int = 50,
|
|
769
|
+
zoom: float = 2,
|
|
770
|
+
) -> Any:
|
|
771
|
+
"""Stack several tables vertically in one block, with an optional shared heading and footer.
|
|
772
|
+
|
|
773
|
+
The stack is HTML, not a ``GT``: each table keeps its own columns, widths and header. Give ``file`` to write it
|
|
774
|
+
straight to an image.
|
|
775
|
+
|
|
776
|
+
Args:
|
|
777
|
+
tables: A list of ``GT`` objects (a dict's values are used in order).
|
|
778
|
+
gap: The space between tables, a non-negative number of pixels.
|
|
779
|
+
align: How tables of differing width line up: ``"center"``, ``"left"`` or ``"right"``.
|
|
780
|
+
title: A heading above the stack: a string, ``md()`` or ``html()``. Plain strings are escaped (as in
|
|
781
|
+
great_tables); use ``html()`` for markup. ``subtitle``, ``caption`` and ``source_note`` take the same.
|
|
782
|
+
subtitle: A line below ``title``.
|
|
783
|
+
caption: A note below the stack.
|
|
784
|
+
source_note: A second line below ``caption``, right-aligned by default.
|
|
785
|
+
caption_rule: Draw a hairline between ``caption`` and ``source_note``.
|
|
786
|
+
title_style: A style dict, with the keys of ``gt_grid``'s ``title_style``.
|
|
787
|
+
subtitle_style: As ``title_style``, for the subtitle.
|
|
788
|
+
caption_style: As ``title_style``, for the caption.
|
|
789
|
+
source_note_style: As ``title_style``, for the source note.
|
|
790
|
+
file: A path to write an image to; ``None`` returns the HTML.
|
|
791
|
+
bg: The background color when saving.
|
|
792
|
+
whitespace: Padding, in pixels, around the stack when saving.
|
|
793
|
+
zoom: The rendering zoom when saving.
|
|
794
|
+
|
|
795
|
+
Returns:
|
|
796
|
+
htmltools.Tag | str | os.PathLike: The stack as HTML (it displays in a notebook), or ``file`` after writing it.
|
|
797
|
+
|
|
798
|
+
Raises:
|
|
799
|
+
TypeError: If ``tables`` holds anything but ``GT`` objects.
|
|
800
|
+
ValueError: If ``tables`` is empty, ``gap`` is not a non-negative number, ``zoom`` is not a positive number,
|
|
801
|
+
``align`` or a style key is unknown, or (when saving) ``bg``, ``whitespace`` or the extension of ``file``
|
|
802
|
+
is invalid.
|
|
803
|
+
nokap.ChromeNotFoundError: If saving and no Chrome or Chromium is installed.
|
|
804
|
+
|
|
805
|
+
Example:
|
|
806
|
+
::
|
|
807
|
+
|
|
808
|
+
import polars as pl
|
|
809
|
+
from great_tables import GT
|
|
810
|
+
from sdvplot.great_tables import gt_stack_tables
|
|
811
|
+
|
|
812
|
+
offense = GT(pl.DataFrame({"team": ["KC", "BUF"], "epa": [0.2, 0.15]}))
|
|
813
|
+
defense = GT(pl.DataFrame({"team": ["BAL", "SF"], "epa": [-0.1, -0.08]}))
|
|
814
|
+
|
|
815
|
+
gt_stack_tables([offense, defense], title="Two tables", title_style={"font": "Oswald", "size": 30})
|
|
816
|
+
|
|
817
|
+
See Also:
|
|
818
|
+
gt_grid: tables side by side.
|
|
819
|
+
Ported from sdvplotR ``gt_stack_tables()``: https://sdvplotR.sportsdataverse.org/reference/gt_stack_tables.html
|
|
820
|
+
"""
|
|
821
|
+
items = _tables(tables)
|
|
822
|
+
places = {"left": "flex-start", "center": "center", "right": "flex-end"}
|
|
823
|
+
if align not in places:
|
|
824
|
+
raise ValueError(f"align must be one of {', '.join(places)}, got {align!r}")
|
|
825
|
+
_pixels("gap", gap)
|
|
826
|
+
_check_zoom(zoom)
|
|
827
|
+
pad = 0 if file is None else _check_common(bg, whitespace, None, file)[0]
|
|
828
|
+
stack = htmltools.div(
|
|
829
|
+
*[_table_html(t) for t in items],
|
|
830
|
+
style=f"display: flex; flex-direction: column; gap: {gap}px; align-items: {places[align]};",
|
|
831
|
+
)
|
|
832
|
+
composed = _compose(
|
|
833
|
+
stack,
|
|
834
|
+
title=title,
|
|
835
|
+
subtitle=subtitle,
|
|
836
|
+
caption=caption,
|
|
837
|
+
source_note=source_note,
|
|
838
|
+
caption_rule=caption_rule,
|
|
839
|
+
title_style=title_style,
|
|
840
|
+
subtitle_style=subtitle_style,
|
|
841
|
+
caption_style=caption_style,
|
|
842
|
+
source_note_style=source_note_style,
|
|
843
|
+
)
|
|
844
|
+
return composed if file is None else _save_composed(composed, file, bg, pad, zoom)
|