ggh4x-python 0.3.1.9000__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.
- ggh4x/__init__.py +140 -0
- ggh4x/_aimed_text_grob.py +432 -0
- ggh4x/_borrowed_ggplot2.py +273 -0
- ggh4x/_cli.py +84 -0
- ggh4x/_datasets.py +106 -0
- ggh4x/_download.py +111 -0
- ggh4x/_facet_helpers.py +313 -0
- ggh4x/_facet_utils.py +649 -0
- ggh4x/_gap_grobs.py +606 -0
- ggh4x/_registry.py +10 -0
- ggh4x/_rlang.py +93 -0
- ggh4x/_utils.py +150 -0
- ggh4x/_vctrs.py +233 -0
- ggh4x/conveniences.py +601 -0
- ggh4x/coord_axes_inside.py +380 -0
- ggh4x/element_part_rect.py +545 -0
- ggh4x/facet_grid2.py +1018 -0
- ggh4x/facet_manual.py +901 -0
- ggh4x/facet_nested.py +776 -0
- ggh4x/facet_nested_wrap.py +193 -0
- ggh4x/facet_wrap2.py +896 -0
- ggh4x/geom_box.py +536 -0
- ggh4x/geom_outline_point.py +444 -0
- ggh4x/geom_pointpath.py +259 -0
- ggh4x/geom_polygonraster.py +252 -0
- ggh4x/geom_rectrug.py +489 -0
- ggh4x/geom_text_aimed.py +279 -0
- ggh4x/guide_stringlegend.py +354 -0
- ggh4x/help_secondary.py +549 -0
- ggh4x/multiscale/__init__.py +51 -0
- ggh4x/multiscale/_multiscale_add.py +207 -0
- ggh4x/multiscale/scale_listed.py +167 -0
- ggh4x/multiscale/scale_manual.py +478 -0
- ggh4x/multiscale/scale_multi.py +393 -0
- ggh4x/panel_scales/__init__.py +58 -0
- ggh4x/panel_scales/at_panel.py +115 -0
- ggh4x/panel_scales/facetted_pos_scales.py +647 -0
- ggh4x/panel_scales/force_panelsize.py +411 -0
- ggh4x/panel_scales/scale_facet.py +222 -0
- ggh4x/position_disjoint_ranges.py +229 -0
- ggh4x/position_lineartrans.py +242 -0
- ggh4x/py.typed +0 -0
- ggh4x/resources/faithful.csv +273 -0
- ggh4x/resources/iris.csv +151 -0
- ggh4x/resources/mtcars.csv +33 -0
- ggh4x/resources/pressure.csv +20 -0
- ggh4x/resources/volcano.csv +87 -0
- ggh4x/save.py +255 -0
- ggh4x/stat_difference.py +388 -0
- ggh4x/stat_funxy.py +436 -0
- ggh4x/stat_rle.py +290 -0
- ggh4x/stat_rollingkernel.py +369 -0
- ggh4x/stat_theodensity.py +681 -0
- ggh4x/strip_nested.py +448 -0
- ggh4x/strip_split.py +687 -0
- ggh4x/strip_tag.py +636 -0
- ggh4x/strip_themed.py +232 -0
- ggh4x/strip_vanilla.py +1464 -0
- ggh4x/themes.py +31 -0
- ggh4x/themes_ggh4x.py +67 -0
- ggh4x_python-0.3.1.9000.dist-info/METADATA +40 -0
- ggh4x_python-0.3.1.9000.dist-info/RECORD +64 -0
- ggh4x_python-0.3.1.9000.dist-info/WHEEL +4 -0
- ggh4x_python-0.3.1.9000.dist-info/licenses/LICENSE +3 -0
ggh4x/strip_vanilla.py
ADDED
|
@@ -0,0 +1,1464 @@
|
|
|
1
|
+
"""Default (vanilla) strips for ggh4x facets (port of ggh4x ``strip_vanilla.R``).
|
|
2
|
+
|
|
3
|
+
This module ports the **base** ``Strip`` ggproto class, the ``strip_vanilla()``
|
|
4
|
+
constructor, and the strip helpers ``resolve_strip`` / ``assert_strip`` /
|
|
5
|
+
``validate_element_list`` / ``inherit_element``. The themed/nested/split/tag
|
|
6
|
+
subclasses live in separate modules.
|
|
7
|
+
|
|
8
|
+
The ``Strip`` hierarchy is a *self-rooted* ggproto hierarchy (its own base, not
|
|
9
|
+
a ggplot2 ``Facet``). It is consumed by the ggh4x facet subsystem, which stores
|
|
10
|
+
a ``Strip`` instance and during panel drawing calls
|
|
11
|
+
``strip.setup(layout, params, theme, type)`` followed by
|
|
12
|
+
``strip.incorporate_grid(panels, switch)`` (grid facets) or
|
|
13
|
+
``strip.incorporate_wrap(panels, position, clip, sizes)`` (wrap/manual facets).
|
|
14
|
+
|
|
15
|
+
R source: ``ggh4x/R/strip_vanilla.R`` (the ``Strip`` base) and the helpers
|
|
16
|
+
``validate_element_list`` / ``inherit_element`` from ``ggh4x/R/strip_themed.R``.
|
|
17
|
+
|
|
18
|
+
Notes on faithful porting decisions (verified against ggplot2 4.0.2 + ggh4x
|
|
19
|
+
0.3.1.9000 run through ``Rscript``):
|
|
20
|
+
|
|
21
|
+
* **Self-less methods.** In R, ``Strip$draw_labels``, ``Strip$init_strip`` and
|
|
22
|
+
``Strip$finish_strip`` are plain functions *without* a ``self`` argument.
|
|
23
|
+
They are installed here as class-level functions whose first parameter is not
|
|
24
|
+
named ``self``, so ``ggproto``'s auto-self-binding leaves them unbound -- a
|
|
25
|
+
call like ``self.draw_labels(labels, ...)`` passes ``labels`` as the first
|
|
26
|
+
positional, never ``self``. ``assemble_strip`` / ``build_strip`` etc. *do*
|
|
27
|
+
take ``self`` and are bound normally.
|
|
28
|
+
|
|
29
|
+
* **Guide system path.** R ``draw_labels`` branches on the package-internal
|
|
30
|
+
``new_guide_system`` flag. On ggplot2 4.0.2 this flag is ``TRUE`` (verified),
|
|
31
|
+
so the **new-guide path** is the gold standard and the only one ported: label
|
|
32
|
+
height/width come straight from ``grob_height`` / ``grob_width`` of the title
|
|
33
|
+
grob (which already includes margins via ``_TitleGrob``), and the
|
|
34
|
+
old-guide-system ``vp$parent$layout`` margin surgery is *not* executed. See
|
|
35
|
+
the module-level note in :func:`_draw_labels_impl`.
|
|
36
|
+
|
|
37
|
+
* **strip.placement precedence.** R ``calc_element('strip.placement.x', theme)
|
|
38
|
+
%||% 'inside' == 'inside'`` parses (verified via the R parse tree) as
|
|
39
|
+
``(calc_element(...) %||% 'inside') == 'inside'`` because ``%||%`` binds
|
|
40
|
+
tighter than ``==``. The faithful Python form is therefore
|
|
41
|
+
``(el if el is not None else 'inside') == 'inside'``.
|
|
42
|
+
|
|
43
|
+
* **Column-major order.** R ``as.vector(col(labels))``, ``matrix()`` reshape
|
|
44
|
+
and ``apply(mat, 1, ...)`` all rely on Fortran (column-major) ordering. The
|
|
45
|
+
port uses ``order='F'`` reshapes / explicit column-major iteration so strip
|
|
46
|
+
cells land in the right panels.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
52
|
+
|
|
53
|
+
import numpy as np
|
|
54
|
+
import pandas as pd
|
|
55
|
+
|
|
56
|
+
from ggplot2_py import (
|
|
57
|
+
calc_element,
|
|
58
|
+
element_grob,
|
|
59
|
+
element_render,
|
|
60
|
+
ggproto,
|
|
61
|
+
is_ggproto,
|
|
62
|
+
is_theme_element,
|
|
63
|
+
max_height,
|
|
64
|
+
max_width,
|
|
65
|
+
)
|
|
66
|
+
from ggplot2_py.ggproto import GGProto
|
|
67
|
+
from grid_py import (
|
|
68
|
+
Unit,
|
|
69
|
+
convert_unit,
|
|
70
|
+
grob_name,
|
|
71
|
+
grob_tree,
|
|
72
|
+
unit_c,
|
|
73
|
+
unit_rep,
|
|
74
|
+
)
|
|
75
|
+
from gtable_py import gtable_matrix
|
|
76
|
+
|
|
77
|
+
from ggh4x._cli import cli_abort
|
|
78
|
+
from ggh4x._rlang import arg_match0
|
|
79
|
+
|
|
80
|
+
__all__ = [
|
|
81
|
+
"Strip",
|
|
82
|
+
"strip_vanilla",
|
|
83
|
+
"resolve_strip",
|
|
84
|
+
"assert_strip",
|
|
85
|
+
"validate_element_list",
|
|
86
|
+
"inherit_element",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# Small helpers
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
def _is_zero_grob(grob: Any) -> bool:
|
|
94
|
+
"""Return ``True`` for R ``is.zero`` -- a ``zeroGrob`` / null grob.
|
|
95
|
+
|
|
96
|
+
``grid_py`` has no dedicated ``zeroGrob`` class; ``null_grob()`` returns a
|
|
97
|
+
plain :class:`grid_py.Grob` named ``GRID.null.N`` (or with ``_grid_class ==
|
|
98
|
+
"null"``). This mirrors ggplot2_py's own ``_is_null_grob`` detection.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
grob : Any
|
|
103
|
+
Candidate grob (or ``None``).
|
|
104
|
+
|
|
105
|
+
Returns
|
|
106
|
+
-------
|
|
107
|
+
bool
|
|
108
|
+
``True`` when *grob* is ``None`` or a null/zero grob.
|
|
109
|
+
"""
|
|
110
|
+
if grob is None:
|
|
111
|
+
return True
|
|
112
|
+
cls = getattr(grob, "_grid_class", "")
|
|
113
|
+
name = getattr(grob, "_name", getattr(grob, "name", ""))
|
|
114
|
+
return cls == "null" or "null" in str(name).lower() or "zero" in str(name).lower()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _rep_len(seq: Sequence[Any], length_out: int) -> List[Any]:
|
|
118
|
+
"""Port of R ``rep_len(seq, length_out)`` (cyclic recycling).
|
|
119
|
+
|
|
120
|
+
Parameters
|
|
121
|
+
----------
|
|
122
|
+
seq : sequence
|
|
123
|
+
Source values (must be non-empty when ``length_out > 0``).
|
|
124
|
+
length_out : int
|
|
125
|
+
Desired output length.
|
|
126
|
+
|
|
127
|
+
Returns
|
|
128
|
+
-------
|
|
129
|
+
list
|
|
130
|
+
*seq* recycled (or truncated) to exactly *length_out* elements.
|
|
131
|
+
"""
|
|
132
|
+
n = len(seq)
|
|
133
|
+
if length_out <= 0 or n == 0:
|
|
134
|
+
return []
|
|
135
|
+
return [seq[i % n] for i in range(length_out)]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class _LabelGrobs(list):
|
|
139
|
+
"""A list of label grobs carrying R ``attr(., "width")`` / ``"height")``.
|
|
140
|
+
|
|
141
|
+
R attaches ``width`` and ``height`` attributes onto the returned label list
|
|
142
|
+
in ``draw_labels``; ``assemble_strip`` reads them back. pandas / Python
|
|
143
|
+
``list`` carry no R-style attributes, so this thin ``list`` subclass holds
|
|
144
|
+
the two unit vectors as plain attributes.
|
|
145
|
+
|
|
146
|
+
Attributes
|
|
147
|
+
----------
|
|
148
|
+
width : grid_py.Unit or None
|
|
149
|
+
Per-layer width unit vector (set by :func:`_draw_labels_impl`).
|
|
150
|
+
height : grid_py.Unit or None
|
|
151
|
+
Per-layer height unit vector.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
width: Any = None
|
|
155
|
+
height: Any = None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
# Self-less Strip methods (R: plain functions WITHOUT a `self` argument)
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
def _init_strip_impl(
|
|
162
|
+
elements: Dict[str, Any],
|
|
163
|
+
position: str,
|
|
164
|
+
layer_index: Sequence[int],
|
|
165
|
+
) -> Dict[str, List[Any]]:
|
|
166
|
+
"""Pick + expand the text/background elements for a strip side.
|
|
167
|
+
|
|
168
|
+
Port of R ``Strip$init_strip`` (``strip_vanilla.R:254-276``) -- a *self-less*
|
|
169
|
+
method. Selects ``elements[['text']][aes][position]`` and
|
|
170
|
+
``elements[['background']][aes]``, wraps singletons in a list, then expands
|
|
171
|
+
to one element per cell either by-layer (``pmin(layer_index, len)``) or by
|
|
172
|
+
cyclic ``rep_len``.
|
|
173
|
+
|
|
174
|
+
Parameters
|
|
175
|
+
----------
|
|
176
|
+
elements : dict
|
|
177
|
+
The resolved element bundle from ``setup_elements`` (keys ``text``,
|
|
178
|
+
``background``, optionally ``by_layer``).
|
|
179
|
+
position : str
|
|
180
|
+
One of ``"top"``, ``"bottom"``, ``"left"``, ``"right"``.
|
|
181
|
+
layer_index : sequence of int
|
|
182
|
+
1-based column index per cell (column-major), from ``col(labels)``.
|
|
183
|
+
|
|
184
|
+
Returns
|
|
185
|
+
-------
|
|
186
|
+
dict
|
|
187
|
+
``{"el": [...], "bg": [...]}`` -- one text element and one background
|
|
188
|
+
element per cell.
|
|
189
|
+
"""
|
|
190
|
+
aes = "x" if position in ("top", "bottom") else "y"
|
|
191
|
+
|
|
192
|
+
el = elements["text"][aes][position]
|
|
193
|
+
el = el if isinstance(el, list) else [el]
|
|
194
|
+
|
|
195
|
+
bg = elements["background"][aes]
|
|
196
|
+
bg = bg if isinstance(bg, list) else [bg]
|
|
197
|
+
|
|
198
|
+
by_layer_map = elements.get("by_layer")
|
|
199
|
+
if by_layer_map is None:
|
|
200
|
+
by_layer = False
|
|
201
|
+
else:
|
|
202
|
+
by_layer = by_layer_map[aes]
|
|
203
|
+
|
|
204
|
+
layer_index = list(layer_index)
|
|
205
|
+
if by_layer:
|
|
206
|
+
# R: el[pmin(layer_index, length(el))] -- 1-based indexing.
|
|
207
|
+
el = [el[min(i, len(el)) - 1] for i in layer_index]
|
|
208
|
+
bg = [bg[min(i, len(bg)) - 1] for i in layer_index]
|
|
209
|
+
else:
|
|
210
|
+
el = _rep_len(el, len(layer_index))
|
|
211
|
+
bg = _rep_len(bg, len(layer_index))
|
|
212
|
+
|
|
213
|
+
return {"el": el, "bg": bg}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _draw_labels_impl(
|
|
217
|
+
labels: Sequence[str],
|
|
218
|
+
element: Dict[str, List[Any]],
|
|
219
|
+
position: str,
|
|
220
|
+
layer_id: Sequence[int],
|
|
221
|
+
size: str,
|
|
222
|
+
) -> _LabelGrobs:
|
|
223
|
+
"""Build per-label title+background grobs and size them per layer.
|
|
224
|
+
|
|
225
|
+
Port of R ``Strip$draw_labels`` (``strip_vanilla.R:145-223``) -- a
|
|
226
|
+
*self-less* method. **Only the new-guide-system path is ported**: on
|
|
227
|
+
ggplot2 4.0.2 the package-internal ``new_guide_system`` flag is ``TRUE``
|
|
228
|
+
(verified), so
|
|
229
|
+
|
|
230
|
+
* label height/width come directly from :func:`grid_py.grob_height` /
|
|
231
|
+
:func:`grid_py.grob_width` of each title grob (which, as a ``_TitleGrob``,
|
|
232
|
+
already includes the element margins);
|
|
233
|
+
* per-layer maxima are taken via :func:`ggplot2_py.max_height` /
|
|
234
|
+
:func:`ggplot2_py.max_width` over groups defined by ``layer_id``;
|
|
235
|
+
* the cross-axis dimension is set to ``unit(1, "null")``.
|
|
236
|
+
|
|
237
|
+
The old-guide-system branch (R lines 152-211) -- which re-injects equalised
|
|
238
|
+
widths/heights into ``grob$widths``, ``grob$heights`` *and* the four-deep
|
|
239
|
+
``grob[[c("vp", "parent", "layout", "widths/heights")]]`` path -- is **not**
|
|
240
|
+
executed, because that path is the new-guide gold standard. ``ggplot2_py``'s
|
|
241
|
+
``_TitleGrob`` exposes ``grob_height``/``grob_width`` that fold in the
|
|
242
|
+
margins, so the new-guide path reproduces R's strip sizing without any
|
|
243
|
+
``vp.parent.layout`` surgery (which does not exist on the Python grob).
|
|
244
|
+
|
|
245
|
+
Parameters
|
|
246
|
+
----------
|
|
247
|
+
labels : sequence of str
|
|
248
|
+
Flattened (column-major) label strings, one per cell.
|
|
249
|
+
element : dict
|
|
250
|
+
``{"el": [...text elements...], "bg": [...background grobs/elements...]}``
|
|
251
|
+
from :func:`_init_strip_impl`, one per cell.
|
|
252
|
+
position : str
|
|
253
|
+
Strip side (``"top"``/``"bottom"``/``"left"``/``"right"``).
|
|
254
|
+
layer_id : sequence of int
|
|
255
|
+
1-based layer (column) id per cell.
|
|
256
|
+
size : str
|
|
257
|
+
``"constant"`` (all layers share one margin set -> ``layer_id`` collapsed
|
|
258
|
+
to all ``1``) or ``"variable"``.
|
|
259
|
+
|
|
260
|
+
Returns
|
|
261
|
+
-------
|
|
262
|
+
_LabelGrobs
|
|
263
|
+
A list of GTree grobs (background + text per cell) with ``.width`` /
|
|
264
|
+
``.height`` unit-vector attributes attached.
|
|
265
|
+
"""
|
|
266
|
+
layer_id = list(layer_id)
|
|
267
|
+
if size == "constant":
|
|
268
|
+
layer_id = [1] * len(layer_id)
|
|
269
|
+
|
|
270
|
+
aes = "x" if position in ("top", "bottom") else "y"
|
|
271
|
+
|
|
272
|
+
# Build the title grob per label (background composited later).
|
|
273
|
+
grobs: List[Any] = []
|
|
274
|
+
for label, elem in zip(labels, element["el"]):
|
|
275
|
+
grob = element_grob(elem, label=label, margin_x=True, margin_y=True)
|
|
276
|
+
# new-guide-system: no add_margins fix-up needed (titleGrob carries
|
|
277
|
+
# margins). Re-name to mirror R grobName(grob, "strip.text.<aes>").
|
|
278
|
+
try:
|
|
279
|
+
grob.name = grob_name(grob, "strip.text." + aes)
|
|
280
|
+
except (AttributeError, TypeError):
|
|
281
|
+
pass
|
|
282
|
+
grobs.append(grob)
|
|
283
|
+
|
|
284
|
+
zeros = [_is_zero_grob(g) for g in grobs]
|
|
285
|
+
|
|
286
|
+
out = _LabelGrobs(grobs)
|
|
287
|
+
if len(grobs) == 0 or all(zeros):
|
|
288
|
+
out.width = None
|
|
289
|
+
out.height = None
|
|
290
|
+
return out
|
|
291
|
+
|
|
292
|
+
nonzero_idx = [i for i, z in enumerate(zeros) if not z]
|
|
293
|
+
nonzero_layer = [layer_id[i] for i in nonzero_idx]
|
|
294
|
+
|
|
295
|
+
if aes == "x":
|
|
296
|
+
# Per-layer max height; width is 1 null per layer.
|
|
297
|
+
heights = [grobs[i] for i in nonzero_idx]
|
|
298
|
+
grouped = _split_by(heights, nonzero_layer)
|
|
299
|
+
height_units = [max_height(g) for g in grouped]
|
|
300
|
+
height = unit_c(*height_units) if len(height_units) > 1 else height_units[0]
|
|
301
|
+
width = unit_rep(Unit(1, "null"), length_out=len(height_units))
|
|
302
|
+
else:
|
|
303
|
+
widths = [grobs[i] for i in nonzero_idx]
|
|
304
|
+
grouped = _split_by(widths, nonzero_layer)
|
|
305
|
+
width_units = [max_width(g) for g in grouped]
|
|
306
|
+
width = unit_c(*width_units) if len(width_units) > 1 else width_units[0]
|
|
307
|
+
height = unit_rep(Unit(1, "null"), length_out=len(width_units))
|
|
308
|
+
|
|
309
|
+
# Combine each label grob with its background into a gTree.
|
|
310
|
+
combined: List[Any] = []
|
|
311
|
+
for x, bg in zip(grobs, element["bg"]):
|
|
312
|
+
bg_grob = element_grob(bg) if is_theme_element(bg) else bg
|
|
313
|
+
tree = grob_tree(bg_grob, x)
|
|
314
|
+
try:
|
|
315
|
+
tree.name = grob_name(tree, "strip")
|
|
316
|
+
except (AttributeError, TypeError):
|
|
317
|
+
pass
|
|
318
|
+
combined.append(tree)
|
|
319
|
+
|
|
320
|
+
result = _LabelGrobs(combined)
|
|
321
|
+
result.width = width
|
|
322
|
+
result.height = height
|
|
323
|
+
return result
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _finish_strip_impl(
|
|
327
|
+
strip: Sequence[Any],
|
|
328
|
+
width: Any,
|
|
329
|
+
height: Any,
|
|
330
|
+
position: str,
|
|
331
|
+
layout: pd.DataFrame,
|
|
332
|
+
dim: Tuple[int, int],
|
|
333
|
+
clip: str = "inherit",
|
|
334
|
+
) -> pd.DataFrame:
|
|
335
|
+
"""Reshape the flat grob list into one gtable per panel; build placement.
|
|
336
|
+
|
|
337
|
+
Port of R ``Strip$finish_strip`` (``strip_vanilla.R:225-252``) -- a
|
|
338
|
+
*self-less* method. The grob list is reshaped ``matrix(strip, ncol=dim[2],
|
|
339
|
+
nrow=dim[1])`` (column-major), then ``apply(strip, 1, ...)`` iterates over
|
|
340
|
+
**rows** (panels) producing a 1-column (horizontal) or 1-row (vertical)
|
|
341
|
+
sub-matrix per panel, each wrapped in :func:`gtable_py.gtable_matrix`.
|
|
342
|
+
|
|
343
|
+
Parameters
|
|
344
|
+
----------
|
|
345
|
+
strip : sequence
|
|
346
|
+
Flattened (column-major) label grobs, length ``dim[0] * dim[1]``.
|
|
347
|
+
width : grid_py.Unit
|
|
348
|
+
Per-layer width unit vector.
|
|
349
|
+
height : grid_py.Unit
|
|
350
|
+
Per-layer height unit vector.
|
|
351
|
+
position : str
|
|
352
|
+
Strip side.
|
|
353
|
+
layout : pandas.DataFrame
|
|
354
|
+
The sliced layout carrying ``PANEL`` (and ``ROW``/``COL``/...).
|
|
355
|
+
dim : tuple of int
|
|
356
|
+
``(nrow, ncol)`` of the label matrix (panels x layers).
|
|
357
|
+
clip : str, default ``"inherit"``
|
|
358
|
+
Clip setting forwarded to ``gtable_matrix``.
|
|
359
|
+
|
|
360
|
+
Returns
|
|
361
|
+
-------
|
|
362
|
+
pandas.DataFrame
|
|
363
|
+
Placement frame with integer ``t``/``l``/``b``/``r`` == ``PANEL`` and an
|
|
364
|
+
object ``grobs`` column holding the per-panel gtables (or the raw grob
|
|
365
|
+
list when empty).
|
|
366
|
+
"""
|
|
367
|
+
strip = list(strip)
|
|
368
|
+
empty_strips = len(strip) == 0 or all(_is_zero_grob(g) for g in strip)
|
|
369
|
+
|
|
370
|
+
horizontal = position in ("top", "bottom")
|
|
371
|
+
out_grobs: List[Any] = strip
|
|
372
|
+
if not empty_strips:
|
|
373
|
+
nrow, ncol = int(dim[0]), int(dim[1])
|
|
374
|
+
# R matrix(strip, ncol, nrow) fills column-major.
|
|
375
|
+
# R matrix(strip, ncol, nrow) fills column-major: element (i, j) is
|
|
376
|
+
# flat[i + j * nrow]. Build the object matrix explicitly so numpy never
|
|
377
|
+
# tries to introspect the GTree grobs as nested sequences.
|
|
378
|
+
mat = [[strip[i + j * nrow] for j in range(ncol)] for i in range(nrow)]
|
|
379
|
+
|
|
380
|
+
out_grobs = []
|
|
381
|
+
for i in range(nrow):
|
|
382
|
+
row = mat[i] # the layers belonging to panel-row i
|
|
383
|
+
if horizontal:
|
|
384
|
+
# apply(strip, 1, matrix, ncol=1) -> one column, nrow == ncol layers.
|
|
385
|
+
sub = [[row[j]] for j in range(ncol)]
|
|
386
|
+
widths = _recycle_unit(width, length_out=1)
|
|
387
|
+
heights = _recycle_unit(height, length_out=ncol)
|
|
388
|
+
else:
|
|
389
|
+
# apply(strip, 1, matrix, nrow=1) -> one row, ncol == ncol layers.
|
|
390
|
+
sub = [[row[j] for j in range(ncol)]]
|
|
391
|
+
widths = _recycle_unit(width, length_out=ncol)
|
|
392
|
+
heights = _recycle_unit(height, length_out=1)
|
|
393
|
+
out_grobs.append(
|
|
394
|
+
gtable_matrix("strip", sub, widths, heights, clip=clip)
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
panel = [int(p) for p in layout["PANEL"]]
|
|
398
|
+
return pd.DataFrame(
|
|
399
|
+
{
|
|
400
|
+
"t": panel,
|
|
401
|
+
"l": panel,
|
|
402
|
+
"b": panel,
|
|
403
|
+
"r": panel,
|
|
404
|
+
"grobs": out_grobs,
|
|
405
|
+
}
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _split_by(values: Sequence[Any], keys: Sequence[Any]) -> List[List[Any]]:
|
|
410
|
+
"""Port of R ``split(values, keys)`` preserving sorted-key group order.
|
|
411
|
+
|
|
412
|
+
R ``split`` groups by the sorted unique key levels. Since ``keys`` here are
|
|
413
|
+
contiguous 1-based layer ids, the groups come out in layer order.
|
|
414
|
+
|
|
415
|
+
Parameters
|
|
416
|
+
----------
|
|
417
|
+
values : sequence
|
|
418
|
+
Values to group.
|
|
419
|
+
keys : sequence
|
|
420
|
+
Grouping key per value.
|
|
421
|
+
|
|
422
|
+
Returns
|
|
423
|
+
-------
|
|
424
|
+
list of list
|
|
425
|
+
One group per sorted-unique key.
|
|
426
|
+
"""
|
|
427
|
+
order = sorted(set(keys))
|
|
428
|
+
groups: Dict[Any, List[Any]] = {k: [] for k in order}
|
|
429
|
+
for v, k in zip(values, keys):
|
|
430
|
+
groups[k].append(v)
|
|
431
|
+
return [groups[k] for k in order]
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _recycle_unit(u: Any, length_out: int) -> Any:
|
|
435
|
+
"""Port of R ``rep(unit, length.out=n)`` for a unit vector.
|
|
436
|
+
|
|
437
|
+
Parameters
|
|
438
|
+
----------
|
|
439
|
+
u : grid_py.Unit
|
|
440
|
+
Source unit vector.
|
|
441
|
+
length_out : int
|
|
442
|
+
Target length.
|
|
443
|
+
|
|
444
|
+
Returns
|
|
445
|
+
-------
|
|
446
|
+
grid_py.Unit
|
|
447
|
+
*u* recycled to *length_out* elements.
|
|
448
|
+
"""
|
|
449
|
+
return unit_rep(u, length_out=length_out)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# ---------------------------------------------------------------------------
|
|
453
|
+
# Strip base ggproto class
|
|
454
|
+
# ---------------------------------------------------------------------------
|
|
455
|
+
class Strip(GGProto):
|
|
456
|
+
"""Base strip class for ggh4x facets (port of R ``Strip``).
|
|
457
|
+
|
|
458
|
+
Subclass of :class:`ggplot2_py.ggproto.GGProto`. Builds strip grobs from a
|
|
459
|
+
facet ``layout`` + ``params`` + ``theme`` and weaves them into the assembled
|
|
460
|
+
panel gtable. Instances are produced by :func:`strip_vanilla` (and the
|
|
461
|
+
subclass constructors).
|
|
462
|
+
|
|
463
|
+
Attributes
|
|
464
|
+
----------
|
|
465
|
+
clip : str
|
|
466
|
+
Class-level default ``"inherit"`` (instances override via ``params``).
|
|
467
|
+
elements : dict
|
|
468
|
+
Resolved theme element bundle (set by :meth:`setup`).
|
|
469
|
+
params : dict
|
|
470
|
+
Strip parameters (``clip``, ``size`` for vanilla).
|
|
471
|
+
strips : dict
|
|
472
|
+
Built strip placement frames, shape ``{"x": {"top", "bottom"},
|
|
473
|
+
"y": {"left", "right"}}`` (set by :meth:`get_strips`).
|
|
474
|
+
|
|
475
|
+
Notes
|
|
476
|
+
-----
|
|
477
|
+
``draw_labels`` / ``init_strip`` / ``finish_strip`` are installed as
|
|
478
|
+
*self-less* class attributes (see module docstring): their first parameter is
|
|
479
|
+
not ``self`` so ggproto does not inject a receiver.
|
|
480
|
+
"""
|
|
481
|
+
|
|
482
|
+
_class_name = "Strip"
|
|
483
|
+
|
|
484
|
+
clip: str = "inherit"
|
|
485
|
+
elements: Dict[str, Any] = {}
|
|
486
|
+
params: Dict[str, Any] = {}
|
|
487
|
+
strips: Dict[str, Any] = {}
|
|
488
|
+
|
|
489
|
+
# --- self-less methods (NOT auto-bound: first arg is not `self`) --------
|
|
490
|
+
draw_labels = staticmethod(_draw_labels_impl)
|
|
491
|
+
init_strip = staticmethod(_init_strip_impl)
|
|
492
|
+
finish_strip = staticmethod(_finish_strip_impl)
|
|
493
|
+
|
|
494
|
+
def setup_elements(self, theme: Any, type: str) -> Dict[str, Any]:
|
|
495
|
+
"""Resolve strip theme elements for one facet kind.
|
|
496
|
+
|
|
497
|
+
Port of R ``Strip$setup_elements`` (``strip_vanilla.R:70-103``).
|
|
498
|
+
Resolves backgrounds (rendered grobs via ``element_render``), per-side
|
|
499
|
+
text elements (``calc_element``), inside/outside placement booleans, and
|
|
500
|
+
the switch padding (converted to cm).
|
|
501
|
+
|
|
502
|
+
Parameters
|
|
503
|
+
----------
|
|
504
|
+
theme : Theme
|
|
505
|
+
The active theme.
|
|
506
|
+
type : str
|
|
507
|
+
``"wrap"`` selects ``strip.switch.pad.wrap`` padding; anything else
|
|
508
|
+
(``"grid"``) selects ``strip.switch.pad.grid``. (Kept as the
|
|
509
|
+
parameter name ``type`` for facet-call compatibility.)
|
|
510
|
+
|
|
511
|
+
Returns
|
|
512
|
+
-------
|
|
513
|
+
dict
|
|
514
|
+
``{"padding", "background", "text", "inside"}``.
|
|
515
|
+
"""
|
|
516
|
+
background = {
|
|
517
|
+
"x": element_render(theme, "strip.background.x"),
|
|
518
|
+
"y": element_render(theme, "strip.background.y"),
|
|
519
|
+
}
|
|
520
|
+
text = {
|
|
521
|
+
"x": {
|
|
522
|
+
"top": calc_element("strip.text.x.top", theme),
|
|
523
|
+
"bottom": calc_element("strip.text.x.bottom", theme),
|
|
524
|
+
},
|
|
525
|
+
"y": {
|
|
526
|
+
"left": calc_element("strip.text.y.left", theme),
|
|
527
|
+
"right": calc_element("strip.text.y.right", theme),
|
|
528
|
+
},
|
|
529
|
+
}
|
|
530
|
+
inside = {
|
|
531
|
+
"x": _placement_inside(calc_element("strip.placement.x", theme)),
|
|
532
|
+
"y": _placement_inside(calc_element("strip.placement.y", theme)),
|
|
533
|
+
}
|
|
534
|
+
pad_name = (
|
|
535
|
+
"strip.switch.pad.wrap" if type == "wrap" else "strip.switch.pad.grid"
|
|
536
|
+
)
|
|
537
|
+
padding = calc_element(pad_name, theme)
|
|
538
|
+
padding = convert_unit(padding, "cm")
|
|
539
|
+
|
|
540
|
+
return {
|
|
541
|
+
"padding": padding,
|
|
542
|
+
"background": background,
|
|
543
|
+
"text": text,
|
|
544
|
+
"inside": inside,
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
def setup(
|
|
548
|
+
self,
|
|
549
|
+
layout: pd.DataFrame,
|
|
550
|
+
params: Dict[str, Any],
|
|
551
|
+
theme: Any,
|
|
552
|
+
type: str,
|
|
553
|
+
) -> None:
|
|
554
|
+
"""Entry point invoked by the facet; resolve elements + build strips.
|
|
555
|
+
|
|
556
|
+
Port of R ``Strip$setup`` (``strip_vanilla.R:105-132``). Stores the
|
|
557
|
+
resolved element bundle on the instance, then builds the per-side label
|
|
558
|
+
frames (``col_vars`` / ``row_vars``) and the sliced ``layout_x`` /
|
|
559
|
+
``layout_y`` frames, branching on wrap vs grid, and delegates to
|
|
560
|
+
:meth:`get_strips`.
|
|
561
|
+
|
|
562
|
+
Parameters
|
|
563
|
+
----------
|
|
564
|
+
layout : pandas.DataFrame
|
|
565
|
+
The facet layout (carries ``PANEL``/``ROW``/``COL`` and the facet
|
|
566
|
+
variable columns).
|
|
567
|
+
params : dict
|
|
568
|
+
Facet params (``facets`` for wrap; ``cols``/``rows`` for grid; plus
|
|
569
|
+
``labeller``).
|
|
570
|
+
theme : Theme
|
|
571
|
+
The active theme.
|
|
572
|
+
type : str
|
|
573
|
+
``"wrap"`` or ``"grid"`` (kept as ``type`` for facet compatibility).
|
|
574
|
+
"""
|
|
575
|
+
self._set(elements=self.setup_elements(theme, type))
|
|
576
|
+
|
|
577
|
+
if type == "wrap":
|
|
578
|
+
facets = params.get("facets") or {}
|
|
579
|
+
facet_names = list(facets.keys()) if hasattr(facets, "keys") else list(facets)
|
|
580
|
+
if len(facet_names) == 0:
|
|
581
|
+
labels = pd.DataFrame({"(all)": ["(all)"]})
|
|
582
|
+
else:
|
|
583
|
+
labels = layout[facet_names].reset_index(drop=True)
|
|
584
|
+
col_vars = labels
|
|
585
|
+
row_vars = labels
|
|
586
|
+
layout_x = layout
|
|
587
|
+
layout_y = layout
|
|
588
|
+
else:
|
|
589
|
+
col_names = _param_names(params.get("cols"))
|
|
590
|
+
row_names = _param_names(params.get("rows"))
|
|
591
|
+
col_mask = _not_duplicated(layout, col_names)
|
|
592
|
+
row_mask = _not_duplicated(layout, row_names)
|
|
593
|
+
layout_x = layout.loc[col_mask]
|
|
594
|
+
layout_y = layout.loc[row_mask]
|
|
595
|
+
col_vars = layout_x[col_names] if col_names else _empty_frame(layout_x)
|
|
596
|
+
row_vars = layout_y[row_names] if row_names else _empty_frame(layout_y)
|
|
597
|
+
|
|
598
|
+
self.get_strips(
|
|
599
|
+
x=_VarFrame(col_vars, type="cols", facet=type),
|
|
600
|
+
y=_VarFrame(row_vars, type="rows", facet=type),
|
|
601
|
+
labeller=params.get("labeller"),
|
|
602
|
+
theme=theme,
|
|
603
|
+
params=self.params,
|
|
604
|
+
layout_x=layout_x,
|
|
605
|
+
layout_y=layout_y,
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
def get_strips(
|
|
609
|
+
self,
|
|
610
|
+
x: Any = None,
|
|
611
|
+
y: Any = None,
|
|
612
|
+
labeller: Any = None,
|
|
613
|
+
theme: Any = None,
|
|
614
|
+
params: Optional[Dict[str, Any]] = None,
|
|
615
|
+
layout_x: Optional[pd.DataFrame] = None,
|
|
616
|
+
layout_y: Optional[pd.DataFrame] = None,
|
|
617
|
+
) -> None:
|
|
618
|
+
"""Build the x and y strips and store them on the instance.
|
|
619
|
+
|
|
620
|
+
Port of R ``Strip$get_strips`` (``strip_vanilla.R:135-143``). Calls
|
|
621
|
+
:meth:`build_strip` for the horizontal (x) and vertical (y) sides and
|
|
622
|
+
writes ``self.strips = {"x": {top, bottom}, "y": {left, right}}``.
|
|
623
|
+
|
|
624
|
+
Parameters
|
|
625
|
+
----------
|
|
626
|
+
x, y : _VarFrame or pandas.DataFrame
|
|
627
|
+
The column / row label frames.
|
|
628
|
+
labeller : callable or str
|
|
629
|
+
Labeller spec.
|
|
630
|
+
theme : Theme
|
|
631
|
+
Active theme.
|
|
632
|
+
params : dict
|
|
633
|
+
Strip params.
|
|
634
|
+
layout_x, layout_y : pandas.DataFrame
|
|
635
|
+
The sliced layout frames for the x / y sides.
|
|
636
|
+
"""
|
|
637
|
+
self._set(
|
|
638
|
+
strips={
|
|
639
|
+
"x": self.build_strip(x, labeller, theme, True, params, layout_x),
|
|
640
|
+
"y": self.build_strip(y, labeller, theme, False, params, layout_y),
|
|
641
|
+
}
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
def assemble_strip(
|
|
645
|
+
self,
|
|
646
|
+
labels: np.ndarray,
|
|
647
|
+
position: str,
|
|
648
|
+
elements: Dict[str, Any],
|
|
649
|
+
params: Dict[str, Any],
|
|
650
|
+
layout: pd.DataFrame,
|
|
651
|
+
) -> pd.DataFrame:
|
|
652
|
+
"""Index, init, draw and finish one strip side.
|
|
653
|
+
|
|
654
|
+
Port of R ``Strip$assemble_strip`` (``strip_vanilla.R:279-290``).
|
|
655
|
+
Computes the column-major layer index ``col(labels)``, delegates to the
|
|
656
|
+
self-less ``init_strip`` / ``draw_labels`` / ``finish_strip``.
|
|
657
|
+
|
|
658
|
+
Parameters
|
|
659
|
+
----------
|
|
660
|
+
labels : numpy.ndarray
|
|
661
|
+
2-D object array of label strings (rows = panels, cols = layers).
|
|
662
|
+
position : str
|
|
663
|
+
Strip side.
|
|
664
|
+
elements : dict
|
|
665
|
+
Resolved element bundle.
|
|
666
|
+
params : dict
|
|
667
|
+
Strip params (``size``, ``clip``).
|
|
668
|
+
layout : pandas.DataFrame
|
|
669
|
+
The sliced layout for this side.
|
|
670
|
+
|
|
671
|
+
Returns
|
|
672
|
+
-------
|
|
673
|
+
pandas.DataFrame
|
|
674
|
+
The placement frame from ``finish_strip``.
|
|
675
|
+
"""
|
|
676
|
+
index = _col_index(labels)
|
|
677
|
+
elems = self.init_strip(elements, position, index)
|
|
678
|
+
strips = self.draw_labels(
|
|
679
|
+
_flatten_col_major(labels), elems, position, index, params["size"]
|
|
680
|
+
)
|
|
681
|
+
width = strips.width
|
|
682
|
+
height = strips.height
|
|
683
|
+
return self.finish_strip(
|
|
684
|
+
strips, width, height, position, layout,
|
|
685
|
+
(labels.shape[0], labels.shape[1]), params["clip"],
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
def build_strip(
|
|
689
|
+
self,
|
|
690
|
+
data: Any,
|
|
691
|
+
labeller: Any,
|
|
692
|
+
theme: Any,
|
|
693
|
+
horizontal: bool,
|
|
694
|
+
params: Dict[str, Any],
|
|
695
|
+
layout: pd.DataFrame,
|
|
696
|
+
) -> Dict[str, Any]:
|
|
697
|
+
"""Format labels into a matrix and assemble strips per side.
|
|
698
|
+
|
|
699
|
+
Port of R ``Strip$build_strip`` (``strip_vanilla.R:293-319``). When the
|
|
700
|
+
data is empty, returns a named ``None`` pair. Otherwise applies the
|
|
701
|
+
labeller per variable (R ``do.call(cbind, lapply(labels(data), cbind))``
|
|
702
|
+
-> rows = panels, cols = variables) and assembles both sides; the right
|
|
703
|
+
strip reverses label columns (inside-out).
|
|
704
|
+
|
|
705
|
+
Parameters
|
|
706
|
+
----------
|
|
707
|
+
data : _VarFrame or pandas.DataFrame
|
|
708
|
+
The per-side label frame.
|
|
709
|
+
labeller : callable or str
|
|
710
|
+
Labeller spec.
|
|
711
|
+
theme : Theme
|
|
712
|
+
Active theme.
|
|
713
|
+
horizontal : bool
|
|
714
|
+
``True`` -> top/bottom strips; ``False`` -> left/right.
|
|
715
|
+
params : dict
|
|
716
|
+
Strip params.
|
|
717
|
+
layout : pandas.DataFrame
|
|
718
|
+
The sliced layout for this side.
|
|
719
|
+
|
|
720
|
+
Returns
|
|
721
|
+
-------
|
|
722
|
+
dict
|
|
723
|
+
``{"top", "bottom"}`` (horizontal) or ``{"left", "right"}``.
|
|
724
|
+
"""
|
|
725
|
+
frame = data.frame if isinstance(data, _VarFrame) else data
|
|
726
|
+
if _empty_data(frame):
|
|
727
|
+
if horizontal:
|
|
728
|
+
return {"top": None, "bottom": None}
|
|
729
|
+
return {"left": None, "right": None}
|
|
730
|
+
|
|
731
|
+
labels = _format_labels(frame, labeller)
|
|
732
|
+
elem = self.elements
|
|
733
|
+
|
|
734
|
+
if horizontal:
|
|
735
|
+
top = self.assemble_strip(labels, "top", elem, params, layout)
|
|
736
|
+
bottom = self.assemble_strip(labels, "bottom", elem, params, layout)
|
|
737
|
+
return {"top": top, "bottom": bottom}
|
|
738
|
+
else:
|
|
739
|
+
revlab = labels[:, ::-1]
|
|
740
|
+
right = self.assemble_strip(revlab, "right", elem, params, layout)
|
|
741
|
+
left = self.assemble_strip(labels, "left", elem, params, layout)
|
|
742
|
+
return {"left": left, "right": right}
|
|
743
|
+
|
|
744
|
+
def incorporate_wrap(
|
|
745
|
+
self,
|
|
746
|
+
panels: Any,
|
|
747
|
+
position: str,
|
|
748
|
+
clip: str = "off",
|
|
749
|
+
sizes: Optional[Dict[str, Any]] = None,
|
|
750
|
+
) -> Any:
|
|
751
|
+
"""Insert strips for one position into wrapped panels.
|
|
752
|
+
|
|
753
|
+
Port of R ``Strip$incorporate_wrap`` (``strip_vanilla.R:322-375``).
|
|
754
|
+
Uses ``weave_panel_rows`` / ``weave_panel_cols`` from
|
|
755
|
+
:mod:`ggh4x._facet_utils` (imported lazily so module import never
|
|
756
|
+
depends on those helpers being present).
|
|
757
|
+
|
|
758
|
+
Parameters
|
|
759
|
+
----------
|
|
760
|
+
panels : Gtable
|
|
761
|
+
The assembled panel gtable.
|
|
762
|
+
position : str
|
|
763
|
+
``"top"``/``"bottom"``/``"left"``/``"right"``.
|
|
764
|
+
clip : str, default ``"off"``
|
|
765
|
+
Clip setting for the strips.
|
|
766
|
+
sizes : dict
|
|
767
|
+
Per-position size unit vectors (used for padding placement).
|
|
768
|
+
|
|
769
|
+
Returns
|
|
770
|
+
-------
|
|
771
|
+
Gtable
|
|
772
|
+
The panel gtable with this position's strips woven in.
|
|
773
|
+
"""
|
|
774
|
+
from ggh4x._facet_utils import (
|
|
775
|
+
split_heights_cm,
|
|
776
|
+
split_widths_cm,
|
|
777
|
+
weave_panel_cols,
|
|
778
|
+
weave_panel_rows,
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
strip_padding = self.elements["padding"]
|
|
782
|
+
size_vec = sizes[position]
|
|
783
|
+
padding = _padding_from_sizes(size_vec, strip_padding)
|
|
784
|
+
|
|
785
|
+
strip = _flatten_strips(self.strips)[position]
|
|
786
|
+
inside = self.elements["inside"]
|
|
787
|
+
side = position[0]
|
|
788
|
+
strip_name = "strip-" + side
|
|
789
|
+
offset = {
|
|
790
|
+
"t": -2 + int(inside["x"]),
|
|
791
|
+
"b": 1 - int(inside["x"]),
|
|
792
|
+
"l": -2 + int(inside["y"]),
|
|
793
|
+
"r": 1 - int(inside["y"]),
|
|
794
|
+
}[side]
|
|
795
|
+
|
|
796
|
+
if side in ("t", "b"):
|
|
797
|
+
strip_height = split_heights_cm(list(strip["grobs"]), list(strip["t"]))
|
|
798
|
+
panels = weave_panel_rows(
|
|
799
|
+
panels, strip, offset, strip_height, strip_name, 2, clip, side
|
|
800
|
+
)
|
|
801
|
+
if not inside["x"]:
|
|
802
|
+
panels = weave_panel_rows(
|
|
803
|
+
panels, row_shift=offset, row_height=padding
|
|
804
|
+
)
|
|
805
|
+
else:
|
|
806
|
+
strip_width = split_widths_cm(list(strip["grobs"]), list(strip["l"]))
|
|
807
|
+
panels = weave_panel_cols(
|
|
808
|
+
panels, strip, offset, strip_width, strip_name, 2, clip, side
|
|
809
|
+
)
|
|
810
|
+
if not inside["y"]:
|
|
811
|
+
panels = weave_panel_cols(
|
|
812
|
+
panels, col_shift=offset, col_width=padding
|
|
813
|
+
)
|
|
814
|
+
return panels
|
|
815
|
+
|
|
816
|
+
def incorporate_grid(self, panels: Any, switch: Any) -> Any:
|
|
817
|
+
"""Insert x then y strips into the assembled grid panel gtable.
|
|
818
|
+
|
|
819
|
+
Port of R ``Strip$incorporate_grid`` (``strip_vanilla.R:378-449``).
|
|
820
|
+
Reads panel cell coordinates from the gtable layout (rows named
|
|
821
|
+
``^panel-``), handles inside/outside placement (adds a padding row/col
|
|
822
|
+
when outside), inserts the strip row/col and adds the strip grobs with
|
|
823
|
+
``z=2``, ``clip="on"``. Panel positions are re-derived after the x block
|
|
824
|
+
because ``gtable_add_rows`` shifts indices.
|
|
825
|
+
|
|
826
|
+
Parameters
|
|
827
|
+
----------
|
|
828
|
+
panels : Gtable
|
|
829
|
+
The assembled panel gtable (axes already attached).
|
|
830
|
+
switch : str or None
|
|
831
|
+
``"x"`` / ``"y"`` / ``"both"`` / ``None`` -- which axes' strips
|
|
832
|
+
switch sides.
|
|
833
|
+
|
|
834
|
+
Returns
|
|
835
|
+
-------
|
|
836
|
+
Gtable
|
|
837
|
+
The panel gtable with strips inserted.
|
|
838
|
+
"""
|
|
839
|
+
from gtable_py import gtable_add_cols, gtable_add_grob, gtable_add_rows
|
|
840
|
+
|
|
841
|
+
switch_x = switch in ("both", "x")
|
|
842
|
+
switch_y = switch in ("both", "y")
|
|
843
|
+
inside = self.elements["inside"]
|
|
844
|
+
padding = self.elements["padding"]
|
|
845
|
+
strips = self.strips
|
|
846
|
+
|
|
847
|
+
pos_cols = _panel_layout(panels)
|
|
848
|
+
|
|
849
|
+
if switch_x:
|
|
850
|
+
side = strips["x"]["bottom"]
|
|
851
|
+
prefix = "strip-b-"
|
|
852
|
+
else:
|
|
853
|
+
side = strips["x"]["top"]
|
|
854
|
+
prefix = "strip-t-"
|
|
855
|
+
strip = _grobs_of(side)
|
|
856
|
+
table = _tlbr_of(side)
|
|
857
|
+
|
|
858
|
+
if strip is not None:
|
|
859
|
+
stripnames = [prefix + str(i + 1) for i in range(len(strip))]
|
|
860
|
+
stripheight = max_height(strip)
|
|
861
|
+
if inside["x"]:
|
|
862
|
+
where = -2 if switch_x else 1
|
|
863
|
+
else:
|
|
864
|
+
where = 0 - int(switch_x)
|
|
865
|
+
panels = gtable_add_rows(panels, padding, where)
|
|
866
|
+
panels = gtable_add_rows(panels, stripheight, where)
|
|
867
|
+
panels = gtable_add_grob(
|
|
868
|
+
panels, strip, name=stripnames,
|
|
869
|
+
t=where + (0 if switch_x else 1),
|
|
870
|
+
l=[pos_cols["l"][li] for li in table["l"]],
|
|
871
|
+
r=[pos_cols["r"][ri] for ri in table["r"]],
|
|
872
|
+
clip="on", z=2,
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
pos_rows = _panel_layout(panels)
|
|
876
|
+
|
|
877
|
+
if switch_y:
|
|
878
|
+
side = strips["y"]["left"]
|
|
879
|
+
prefix = "strip-l-"
|
|
880
|
+
else:
|
|
881
|
+
side = strips["y"]["right"]
|
|
882
|
+
prefix = "strip-r-"
|
|
883
|
+
strip = _grobs_of(side)
|
|
884
|
+
table = _tlbr_of(side)
|
|
885
|
+
|
|
886
|
+
if strip is not None:
|
|
887
|
+
stripnames = [prefix + str(i + 1) for i in range(len(strip))]
|
|
888
|
+
stripwidth = max_width(strip)
|
|
889
|
+
if inside["y"]:
|
|
890
|
+
where = 1 if switch_y else -2
|
|
891
|
+
else:
|
|
892
|
+
where = -1 + int(switch_y)
|
|
893
|
+
panels = gtable_add_cols(panels, padding, where)
|
|
894
|
+
panels = gtable_add_cols(panels, stripwidth, where)
|
|
895
|
+
panels = gtable_add_grob(
|
|
896
|
+
panels, strip, name=stripnames,
|
|
897
|
+
t=[pos_rows["t"][ti] for ti in table["t"]],
|
|
898
|
+
b=[pos_rows["b"][bi] for bi in table["b"]],
|
|
899
|
+
l=where + int(switch_y),
|
|
900
|
+
clip="on", z=2,
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
return panels
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
# Re-bind the self-less methods on the class so attribute access returns the
|
|
907
|
+
# raw functions (staticmethod is unwrapped on access, leaving no `self`).
|
|
908
|
+
Strip.draw_labels = staticmethod(_draw_labels_impl)
|
|
909
|
+
Strip.init_strip = staticmethod(_init_strip_impl)
|
|
910
|
+
Strip.finish_strip = staticmethod(_finish_strip_impl)
|
|
911
|
+
|
|
912
|
+
# R's ``Strip`` is a ggproto *instance* (env), used as the parent in every
|
|
913
|
+
# constructor. The Python ``Strip`` is a class; this module-level singleton is
|
|
914
|
+
# the instance the constructors clone (instance-as-parent path). Subclass
|
|
915
|
+
# constructors should likewise clone their own singletons.
|
|
916
|
+
_STRIP_SINGLETON: "Strip" = Strip()
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
# ---------------------------------------------------------------------------
|
|
920
|
+
# Internal data-frame side-channel + layout helpers
|
|
921
|
+
# ---------------------------------------------------------------------------
|
|
922
|
+
class _VarFrame:
|
|
923
|
+
"""Carry R ``attr(., "type")`` / ``attr(., "facet")`` alongside a frame.
|
|
924
|
+
|
|
925
|
+
R attaches ``type = "cols"/"rows"`` and ``facet = type`` onto the var frame
|
|
926
|
+
via ``structure()`` / ``attr<-``; pandas has no per-object attribute slot, so
|
|
927
|
+
this thin wrapper carries them out of band.
|
|
928
|
+
|
|
929
|
+
Parameters
|
|
930
|
+
----------
|
|
931
|
+
frame : pandas.DataFrame
|
|
932
|
+
The label var frame.
|
|
933
|
+
type : str
|
|
934
|
+
``"cols"`` or ``"rows"``.
|
|
935
|
+
facet : str
|
|
936
|
+
The facet kind (``"grid"`` / ``"wrap"``).
|
|
937
|
+
"""
|
|
938
|
+
|
|
939
|
+
__slots__ = ("frame", "type", "facet")
|
|
940
|
+
|
|
941
|
+
def __init__(self, frame: pd.DataFrame, type: str, facet: str) -> None:
|
|
942
|
+
self.frame = frame
|
|
943
|
+
self.type = type
|
|
944
|
+
self.facet = facet
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _param_names(param: Any) -> List[str]:
|
|
948
|
+
"""Return the variable names from a facet ``cols``/``rows`` param.
|
|
949
|
+
|
|
950
|
+
R uses ``names(params$cols)``. The param may be a dict / mapping, a list of
|
|
951
|
+
names, or ``None``.
|
|
952
|
+
|
|
953
|
+
Parameters
|
|
954
|
+
----------
|
|
955
|
+
param : Any
|
|
956
|
+
A ``cols``/``rows`` spec.
|
|
957
|
+
|
|
958
|
+
Returns
|
|
959
|
+
-------
|
|
960
|
+
list of str
|
|
961
|
+
"""
|
|
962
|
+
if param is None:
|
|
963
|
+
return []
|
|
964
|
+
if hasattr(param, "keys"):
|
|
965
|
+
return list(param.keys())
|
|
966
|
+
if isinstance(param, (list, tuple)):
|
|
967
|
+
return [str(p) for p in param]
|
|
968
|
+
return []
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def _empty_frame(df: pd.DataFrame) -> pd.DataFrame:
|
|
972
|
+
"""Return a 0-column frame with the same row count as *df*.
|
|
973
|
+
|
|
974
|
+
Mirrors R ``layout[character(0)]`` -- a frame with ``nrow(layout)`` rows and
|
|
975
|
+
no columns, whose ``duplicated()`` is all-but-first ``TRUE``.
|
|
976
|
+
"""
|
|
977
|
+
return pd.DataFrame(index=df.index)
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _not_duplicated(layout: pd.DataFrame, names: List[str]) -> np.ndarray:
|
|
981
|
+
"""Port of R ``!duplicated(layout[names])`` (boolean keep-mask).
|
|
982
|
+
|
|
983
|
+
When *names* is empty, R ``layout[character(0)]`` is a 0-column frame and
|
|
984
|
+
``duplicated()`` treats every row as identical -- only the first row is kept.
|
|
985
|
+
pandas ``DataFrame.duplicated()`` on a 0-column frame returns a length-0
|
|
986
|
+
array, so that case is handled explicitly.
|
|
987
|
+
|
|
988
|
+
Parameters
|
|
989
|
+
----------
|
|
990
|
+
layout : pandas.DataFrame
|
|
991
|
+
The facet layout.
|
|
992
|
+
names : list of str
|
|
993
|
+
Column names to de-duplicate on.
|
|
994
|
+
|
|
995
|
+
Returns
|
|
996
|
+
-------
|
|
997
|
+
numpy.ndarray of bool
|
|
998
|
+
``True`` for the first occurrence of each unique combination.
|
|
999
|
+
"""
|
|
1000
|
+
n = layout.shape[0]
|
|
1001
|
+
if not names:
|
|
1002
|
+
mask = np.zeros(n, dtype=bool)
|
|
1003
|
+
if n > 0:
|
|
1004
|
+
mask[0] = True
|
|
1005
|
+
return mask
|
|
1006
|
+
return ~layout[names].duplicated().to_numpy()
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _placement_inside(el: Any) -> bool:
|
|
1010
|
+
"""Faithful port of R ``calc_element('strip.placement.x', th) %||% 'inside' == 'inside'``.
|
|
1011
|
+
|
|
1012
|
+
Verified against the R parse tree: ``%||%`` binds tighter than ``==``, so the
|
|
1013
|
+
expression is ``(el %||% 'inside') == 'inside'``.
|
|
1014
|
+
|
|
1015
|
+
Parameters
|
|
1016
|
+
----------
|
|
1017
|
+
el : str or None
|
|
1018
|
+
The resolved ``strip.placement.*`` value.
|
|
1019
|
+
|
|
1020
|
+
Returns
|
|
1021
|
+
-------
|
|
1022
|
+
bool
|
|
1023
|
+
``True`` for ``"inside"`` (or unset / ``None``); ``False`` otherwise.
|
|
1024
|
+
"""
|
|
1025
|
+
return (el if el is not None else "inside") == "inside"
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def _empty_data(frame: Any) -> bool:
|
|
1029
|
+
"""Port of R ``empty(data)`` for the var frame.
|
|
1030
|
+
|
|
1031
|
+
R ``empty()`` is ``TRUE`` for ``NULL`` / zero-row / zero-column frames.
|
|
1032
|
+
|
|
1033
|
+
Parameters
|
|
1034
|
+
----------
|
|
1035
|
+
frame : Any
|
|
1036
|
+
The label var frame (or ``None``).
|
|
1037
|
+
|
|
1038
|
+
Returns
|
|
1039
|
+
-------
|
|
1040
|
+
bool
|
|
1041
|
+
"""
|
|
1042
|
+
if frame is None:
|
|
1043
|
+
return True
|
|
1044
|
+
if isinstance(frame, pd.DataFrame):
|
|
1045
|
+
return frame.shape[0] == 0 or frame.shape[1] == 0
|
|
1046
|
+
return len(frame) == 0
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def _format_labels(frame: pd.DataFrame, labeller: Any) -> np.ndarray:
|
|
1050
|
+
"""Apply the labeller per variable and column-stack into a string matrix.
|
|
1051
|
+
|
|
1052
|
+
Port of R ``do.call(cbind, lapply(labels(data), cbind))`` where
|
|
1053
|
+
``labels(data)`` returns a per-variable list of character vectors. The
|
|
1054
|
+
ggplot2_py labellers collapse multiple variables into one flat list, so the
|
|
1055
|
+
labeller is applied **per column** to reproduce R's per-variable matrix
|
|
1056
|
+
(rows = panels, cols = variables/layers).
|
|
1057
|
+
|
|
1058
|
+
Parameters
|
|
1059
|
+
----------
|
|
1060
|
+
frame : pandas.DataFrame
|
|
1061
|
+
The label var frame.
|
|
1062
|
+
labeller : callable or str
|
|
1063
|
+
Labeller spec (resolved via ``as_labeller``).
|
|
1064
|
+
|
|
1065
|
+
Returns
|
|
1066
|
+
-------
|
|
1067
|
+
numpy.ndarray
|
|
1068
|
+
2-D object array, rows = panels, cols = variables.
|
|
1069
|
+
"""
|
|
1070
|
+
from ggplot2_py.labeller import as_labeller, label_value
|
|
1071
|
+
|
|
1072
|
+
if labeller is None:
|
|
1073
|
+
labeller_fn = label_value
|
|
1074
|
+
elif callable(labeller):
|
|
1075
|
+
labeller_fn = labeller
|
|
1076
|
+
else:
|
|
1077
|
+
labeller_fn = as_labeller(labeller)
|
|
1078
|
+
|
|
1079
|
+
cols: List[List[str]] = []
|
|
1080
|
+
for name in frame.columns:
|
|
1081
|
+
values = [str(v) for v in frame[name].tolist()]
|
|
1082
|
+
out = labeller_fn({str(name): values})
|
|
1083
|
+
# Labeller may return a dict (per-variable) or a flat list.
|
|
1084
|
+
if isinstance(out, dict):
|
|
1085
|
+
out = list(out.values())[0]
|
|
1086
|
+
cols.append([str(v) for v in out])
|
|
1087
|
+
|
|
1088
|
+
nrow = frame.shape[0]
|
|
1089
|
+
ncol = len(cols)
|
|
1090
|
+
mat = np.empty((nrow, ncol), dtype=object)
|
|
1091
|
+
for j, col in enumerate(cols):
|
|
1092
|
+
for i in range(nrow):
|
|
1093
|
+
mat[i, j] = col[i]
|
|
1094
|
+
return mat
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def _col_index(labels: np.ndarray) -> List[int]:
|
|
1098
|
+
"""Port of R ``as.vector(col(labels))`` -- column number per cell, column-major.
|
|
1099
|
+
|
|
1100
|
+
For a ``nrow x ncol`` matrix this is ``rep(1:ncol, each=nrow)``.
|
|
1101
|
+
|
|
1102
|
+
Parameters
|
|
1103
|
+
----------
|
|
1104
|
+
labels : numpy.ndarray
|
|
1105
|
+
2-D label matrix.
|
|
1106
|
+
|
|
1107
|
+
Returns
|
|
1108
|
+
-------
|
|
1109
|
+
list of int
|
|
1110
|
+
1-based column index per flattened (column-major) cell.
|
|
1111
|
+
"""
|
|
1112
|
+
nrow, ncol = labels.shape
|
|
1113
|
+
return [j + 1 for j in range(ncol) for _ in range(nrow)]
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def _flatten_col_major(labels: np.ndarray) -> List[Any]:
|
|
1117
|
+
"""Flatten a 2-D matrix column-major (R ``as.vector`` order).
|
|
1118
|
+
|
|
1119
|
+
Parameters
|
|
1120
|
+
----------
|
|
1121
|
+
labels : numpy.ndarray
|
|
1122
|
+
2-D matrix.
|
|
1123
|
+
|
|
1124
|
+
Returns
|
|
1125
|
+
-------
|
|
1126
|
+
list
|
|
1127
|
+
Column-major flattened values.
|
|
1128
|
+
"""
|
|
1129
|
+
return list(labels.reshape(-1, order="F"))
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def _panel_layout(panels: Any) -> Dict[str, List[int]]:
|
|
1133
|
+
"""Return panel-cell ``t``/``b``/``l``/``r`` lists from a gtable layout.
|
|
1134
|
+
|
|
1135
|
+
Mirrors R ``panels$layout[grepl('^panel-', panels$layout$name), ]`` -- but
|
|
1136
|
+
keeps **all** matching rows (not de-duplicated) and 1-based indexable by the
|
|
1137
|
+
strip table's ``t``/``l``/``b``/``r`` panel ids. ``gtable_py`` stores the
|
|
1138
|
+
layout as a dict of parallel lists.
|
|
1139
|
+
|
|
1140
|
+
Parameters
|
|
1141
|
+
----------
|
|
1142
|
+
panels : Gtable
|
|
1143
|
+
The assembled panel gtable.
|
|
1144
|
+
|
|
1145
|
+
Returns
|
|
1146
|
+
-------
|
|
1147
|
+
dict
|
|
1148
|
+
``{"t": [...], "b": [...], "l": [...], "r": [...]}`` with a leading
|
|
1149
|
+
``None`` so the lists are 1-based addressable (``[panel_id]``).
|
|
1150
|
+
"""
|
|
1151
|
+
import re
|
|
1152
|
+
|
|
1153
|
+
lay = panels.layout
|
|
1154
|
+
if isinstance(lay, pd.DataFrame):
|
|
1155
|
+
names = list(lay["name"])
|
|
1156
|
+
t = list(lay["t"]); b = list(lay["b"]); l = list(lay["l"]); r = list(lay["r"])
|
|
1157
|
+
else:
|
|
1158
|
+
names = list(lay["name"])
|
|
1159
|
+
t = list(lay["t"]); b = list(lay["b"]); l = list(lay["l"]); r = list(lay["r"])
|
|
1160
|
+
|
|
1161
|
+
rx = re.compile(r"^panel-")
|
|
1162
|
+
sel_t: List[Optional[int]] = [None]
|
|
1163
|
+
sel_b: List[Optional[int]] = [None]
|
|
1164
|
+
sel_l: List[Optional[int]] = [None]
|
|
1165
|
+
sel_r: List[Optional[int]] = [None]
|
|
1166
|
+
for i, nm in enumerate(names):
|
|
1167
|
+
if rx.match(str(nm)):
|
|
1168
|
+
sel_t.append(int(t[i]))
|
|
1169
|
+
sel_b.append(int(b[i]))
|
|
1170
|
+
sel_l.append(int(l[i]))
|
|
1171
|
+
sel_r.append(int(r[i]))
|
|
1172
|
+
return {"t": sel_t, "b": sel_b, "l": sel_l, "r": sel_r}
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def _grobs_of(side: Any) -> Optional[List[Any]]:
|
|
1176
|
+
"""Return the ``grobs`` list of a built strip side (or ``None``).
|
|
1177
|
+
|
|
1178
|
+
Parameters
|
|
1179
|
+
----------
|
|
1180
|
+
side : pandas.DataFrame or None
|
|
1181
|
+
A built strip placement frame.
|
|
1182
|
+
|
|
1183
|
+
Returns
|
|
1184
|
+
-------
|
|
1185
|
+
list or None
|
|
1186
|
+
"""
|
|
1187
|
+
if side is None:
|
|
1188
|
+
return None
|
|
1189
|
+
grobs = list(side["grobs"])
|
|
1190
|
+
return grobs
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def _tlbr_of(side: Any) -> Optional[Dict[str, List[int]]]:
|
|
1194
|
+
"""Return ``{t,l,b,r}`` panel-id lists, or ``None`` for a ``None`` side.
|
|
1195
|
+
|
|
1196
|
+
The strip placement frame's ``t``/``l``/``b``/``r`` are panel ids used to
|
|
1197
|
+
index into the panel-layout lists. Mirrors R ``NULL[c("t","b","l","r")]``
|
|
1198
|
+
-> ``NULL`` when the strip side is empty.
|
|
1199
|
+
|
|
1200
|
+
Parameters
|
|
1201
|
+
----------
|
|
1202
|
+
side : pandas.DataFrame or None
|
|
1203
|
+
A built strip placement frame.
|
|
1204
|
+
|
|
1205
|
+
Returns
|
|
1206
|
+
-------
|
|
1207
|
+
dict or None
|
|
1208
|
+
"""
|
|
1209
|
+
if side is None:
|
|
1210
|
+
return None
|
|
1211
|
+
return {
|
|
1212
|
+
"t": [int(v) for v in side["t"]],
|
|
1213
|
+
"l": [int(v) for v in side["l"]],
|
|
1214
|
+
"b": [int(v) for v in side["b"]],
|
|
1215
|
+
"r": [int(v) for v in side["r"]],
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
def _flatten_strips(strips: Dict[str, Any]) -> Dict[str, Any]:
|
|
1220
|
+
"""Port of R ``unlist(unname(self$strips), recursive=FALSE)``.
|
|
1221
|
+
|
|
1222
|
+
Flattens ``{"x": {"top", "bottom"}, "y": {"left", "right"}}`` into a single
|
|
1223
|
+
dict keyed by the inner side names (``top``/``bottom``/``left``/``right``).
|
|
1224
|
+
|
|
1225
|
+
Parameters
|
|
1226
|
+
----------
|
|
1227
|
+
strips : dict
|
|
1228
|
+
The nested strip dict.
|
|
1229
|
+
|
|
1230
|
+
Returns
|
|
1231
|
+
-------
|
|
1232
|
+
dict
|
|
1233
|
+
Keyed by side name.
|
|
1234
|
+
"""
|
|
1235
|
+
flat: Dict[str, Any] = {}
|
|
1236
|
+
for outer in strips.values():
|
|
1237
|
+
for key, value in outer.items():
|
|
1238
|
+
flat[key] = value
|
|
1239
|
+
return flat
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
def _padding_from_sizes(size_vec: Any, strip_padding: Any) -> Any:
|
|
1243
|
+
"""Port of R ``padding[as.numeric(padding) != 0] <- strip_padding``.
|
|
1244
|
+
|
|
1245
|
+
Copies the per-position size unit vector and overwrites its non-zero entries
|
|
1246
|
+
with the scalar strip padding (so padding lands only where an axis sits).
|
|
1247
|
+
|
|
1248
|
+
Parameters
|
|
1249
|
+
----------
|
|
1250
|
+
size_vec : grid_py.Unit
|
|
1251
|
+
The ``sizes[[position]]`` unit vector.
|
|
1252
|
+
strip_padding : grid_py.Unit
|
|
1253
|
+
Scalar padding (in cm).
|
|
1254
|
+
|
|
1255
|
+
Returns
|
|
1256
|
+
-------
|
|
1257
|
+
grid_py.Unit
|
|
1258
|
+
The reconstructed padding unit vector.
|
|
1259
|
+
"""
|
|
1260
|
+
values = convert_unit(size_vec, "cm", valueOnly=True)
|
|
1261
|
+
values = np.atleast_1d(np.asarray(values, dtype="float64"))
|
|
1262
|
+
pad_cm = float(np.atleast_1d(convert_unit(strip_padding, "cm", valueOnly=True))[0])
|
|
1263
|
+
units: List[Any] = []
|
|
1264
|
+
for v in values:
|
|
1265
|
+
if v != 0:
|
|
1266
|
+
units.append(Unit(pad_cm, "cm"))
|
|
1267
|
+
else:
|
|
1268
|
+
units.append(Unit(float(v), "cm"))
|
|
1269
|
+
if len(units) == 0:
|
|
1270
|
+
return Unit([], "cm")
|
|
1271
|
+
if len(units) == 1:
|
|
1272
|
+
return units[0]
|
|
1273
|
+
return unit_c(*units)
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
# ---------------------------------------------------------------------------
|
|
1277
|
+
# Constructor
|
|
1278
|
+
# ---------------------------------------------------------------------------
|
|
1279
|
+
def strip_vanilla(clip: str = "inherit", size: str = "constant") -> Strip:
|
|
1280
|
+
"""Create a default (vanilla ggplot2 style) strip.
|
|
1281
|
+
|
|
1282
|
+
Port of R ``strip_vanilla()`` (``strip_vanilla.R:41-51``).
|
|
1283
|
+
|
|
1284
|
+
Parameters
|
|
1285
|
+
----------
|
|
1286
|
+
clip : str, default ``"inherit"``
|
|
1287
|
+
Whether text labels are clipped to the background boxes; one of
|
|
1288
|
+
``"inherit"``, ``"on"``, ``"off"``.
|
|
1289
|
+
size : str, default ``"constant"``
|
|
1290
|
+
Whether strip margins across layers remain ``"constant"`` or are
|
|
1291
|
+
``"variable"``.
|
|
1292
|
+
|
|
1293
|
+
Returns
|
|
1294
|
+
-------
|
|
1295
|
+
Strip
|
|
1296
|
+
A ``Strip`` ggproto instance usable in ggh4x facets.
|
|
1297
|
+
"""
|
|
1298
|
+
params = {
|
|
1299
|
+
"clip": arg_match0(clip, ["on", "off", "inherit"], arg_name="clip"),
|
|
1300
|
+
"size": arg_match0(size, ["constant", "variable"], arg_name="size"),
|
|
1301
|
+
}
|
|
1302
|
+
# R: ggproto(NULL, Strip, params=...). R's ``Strip`` is itself a ggproto
|
|
1303
|
+
# *instance*, so this is the instance-as-parent path -> returns an instance
|
|
1304
|
+
# (a clone of the ``Strip`` singleton with ``params`` overridden).
|
|
1305
|
+
return ggproto(None, _STRIP_SINGLETON, params=params)
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
# ---------------------------------------------------------------------------
|
|
1309
|
+
# Helpers
|
|
1310
|
+
# ---------------------------------------------------------------------------
|
|
1311
|
+
def resolve_strip(strip: Any, arg: str = "strip", env: Any = None) -> Strip:
|
|
1312
|
+
"""Resolve a strip specification into a ``Strip`` instance.
|
|
1313
|
+
|
|
1314
|
+
Port of R ``resolve_strip()`` (``strip_vanilla.R:454-470``). A string
|
|
1315
|
+
``"vanilla"`` maps to the ``strip_vanilla`` constructor (R ``find_global``);
|
|
1316
|
+
a callable is invoked; a ``Strip`` instance passes through. Anything else
|
|
1317
|
+
raises.
|
|
1318
|
+
|
|
1319
|
+
Parameters
|
|
1320
|
+
----------
|
|
1321
|
+
strip : str or callable or Strip
|
|
1322
|
+
The strip spec. Strings name a ``strip_<name>`` constructor in this
|
|
1323
|
+
module's namespace.
|
|
1324
|
+
arg : str, default ``"strip"``
|
|
1325
|
+
Argument name for the error message.
|
|
1326
|
+
env : Any, optional
|
|
1327
|
+
Unused (kept for R signature parity / ``find_global`` env).
|
|
1328
|
+
|
|
1329
|
+
Returns
|
|
1330
|
+
-------
|
|
1331
|
+
Strip
|
|
1332
|
+
A resolved ``Strip`` instance.
|
|
1333
|
+
|
|
1334
|
+
Raises
|
|
1335
|
+
------
|
|
1336
|
+
ValueError
|
|
1337
|
+
When *strip* cannot be resolved to a ``Strip``.
|
|
1338
|
+
"""
|
|
1339
|
+
if isinstance(strip, str):
|
|
1340
|
+
fn = globals().get("strip_" + strip)
|
|
1341
|
+
if fn is None:
|
|
1342
|
+
# Allow subclass constructors registered elsewhere (lazy).
|
|
1343
|
+
try:
|
|
1344
|
+
import ggh4x as _pkg # noqa: F401
|
|
1345
|
+
|
|
1346
|
+
fn = getattr(_pkg, "strip_" + strip, None)
|
|
1347
|
+
except ImportError:
|
|
1348
|
+
fn = None
|
|
1349
|
+
strip = fn
|
|
1350
|
+
|
|
1351
|
+
if callable(strip):
|
|
1352
|
+
strip = strip()
|
|
1353
|
+
|
|
1354
|
+
if is_ggproto(strip) and isinstance(strip, Strip):
|
|
1355
|
+
return strip
|
|
1356
|
+
|
|
1357
|
+
cli_abort(
|
|
1358
|
+
f"The `{arg}` argument must be a valid strip specification."
|
|
1359
|
+
)
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
# fallback for {deeptime}
|
|
1363
|
+
assert_strip = resolve_strip
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
def validate_element_list(
|
|
1367
|
+
elem: Any,
|
|
1368
|
+
prototype: str = "element_text",
|
|
1369
|
+
) -> Optional[List[Any]]:
|
|
1370
|
+
"""Validate a user-supplied list of theme elements.
|
|
1371
|
+
|
|
1372
|
+
Port of R ``validate_element_list()`` (``strip_themed.R:188-207``). ``None``
|
|
1373
|
+
passes through; a non-list is wrapped in a list; every item must be a blank
|
|
1374
|
+
element, an element of the *prototype* class, or ``None`` -- else it aborts.
|
|
1375
|
+
|
|
1376
|
+
Parameters
|
|
1377
|
+
----------
|
|
1378
|
+
elem : Any
|
|
1379
|
+
``None``, a single element, or a list of elements.
|
|
1380
|
+
prototype : str, default ``"element_text"``
|
|
1381
|
+
Expected element class name (``"element_text"`` / ``"element_rect"``);
|
|
1382
|
+
the leading ``"element_"`` is stripped for the type check.
|
|
1383
|
+
|
|
1384
|
+
Returns
|
|
1385
|
+
-------
|
|
1386
|
+
list or None
|
|
1387
|
+
The validated list (or ``None``).
|
|
1388
|
+
|
|
1389
|
+
Raises
|
|
1390
|
+
------
|
|
1391
|
+
ValueError
|
|
1392
|
+
When any item is not blank / prototype-typed / ``None``.
|
|
1393
|
+
"""
|
|
1394
|
+
if elem is None:
|
|
1395
|
+
return None
|
|
1396
|
+
if not isinstance(elem, list):
|
|
1397
|
+
elem = [elem]
|
|
1398
|
+
|
|
1399
|
+
proto_type = prototype[len("element_"):] if prototype.startswith("element_") else prototype
|
|
1400
|
+
|
|
1401
|
+
invalid = []
|
|
1402
|
+
for x in elem:
|
|
1403
|
+
ok = (
|
|
1404
|
+
is_theme_element(x, "blank")
|
|
1405
|
+
or is_theme_element(x, proto_type)
|
|
1406
|
+
or x is None
|
|
1407
|
+
)
|
|
1408
|
+
invalid.append(not ok)
|
|
1409
|
+
|
|
1410
|
+
if any(invalid):
|
|
1411
|
+
cli_abort(
|
|
1412
|
+
f"The argument should be a list of `{prototype}` objects."
|
|
1413
|
+
)
|
|
1414
|
+
return elem
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
def inherit_element(child: Any, parent: Any) -> Any:
|
|
1418
|
+
"""Resolve a child element's ``None`` properties from a parent element.
|
|
1419
|
+
|
|
1420
|
+
Port of R ``inherit_element()`` (``strip_themed.R:211-248``), a
|
|
1421
|
+
``combine_elements``-equivalent with ggh4x's exact early-return order. On
|
|
1422
|
+
ggplot2 4.0.x the theme elements are S7 objects, so the property-copy branch
|
|
1423
|
+
(fill ``None`` props from *parent*) is taken; the ``rel`` size-multiplication
|
|
1424
|
+
branch lives in the **non-S7** path and -- matching the gold standard run --
|
|
1425
|
+
is *not* executed (verified: ``rel(2)`` against a parent size of 10 stays at
|
|
1426
|
+
2, not 20). This port reproduces the S7 behaviour: ``None`` props are filled
|
|
1427
|
+
from *parent* and ``Rel`` sizes are left untouched.
|
|
1428
|
+
|
|
1429
|
+
Parameters
|
|
1430
|
+
----------
|
|
1431
|
+
child : Any
|
|
1432
|
+
The child element (or value).
|
|
1433
|
+
parent : Any
|
|
1434
|
+
The parent element to inherit from.
|
|
1435
|
+
|
|
1436
|
+
Returns
|
|
1437
|
+
-------
|
|
1438
|
+
Any
|
|
1439
|
+
The resolved element.
|
|
1440
|
+
"""
|
|
1441
|
+
# 1. parent NULL or child blank -> child verbatim.
|
|
1442
|
+
if parent is None or is_theme_element(child, "blank"):
|
|
1443
|
+
return child
|
|
1444
|
+
# 2. child NULL -> parent.
|
|
1445
|
+
if child is None:
|
|
1446
|
+
return parent
|
|
1447
|
+
# 3. neither is a theme element -> child.
|
|
1448
|
+
if not is_theme_element(child) and not is_theme_element(parent):
|
|
1449
|
+
return child
|
|
1450
|
+
# 4. parent blank -> obey child.inherit.blank.
|
|
1451
|
+
if is_theme_element(parent, "blank"):
|
|
1452
|
+
if getattr(child, "inherit_blank", False):
|
|
1453
|
+
return parent
|
|
1454
|
+
return child
|
|
1455
|
+
|
|
1456
|
+
# 5. Fill child's None props from parent (S7-props-copy branch).
|
|
1457
|
+
import copy as _copy
|
|
1458
|
+
|
|
1459
|
+
result = _copy.copy(child)
|
|
1460
|
+
for attr in list(parent.__dict__.keys()):
|
|
1461
|
+
if attr in result.__dict__ and getattr(result, attr) is None:
|
|
1462
|
+
setattr(result, attr, getattr(parent, attr))
|
|
1463
|
+
|
|
1464
|
+
return result
|