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.
Files changed (64) hide show
  1. ggh4x/__init__.py +140 -0
  2. ggh4x/_aimed_text_grob.py +432 -0
  3. ggh4x/_borrowed_ggplot2.py +273 -0
  4. ggh4x/_cli.py +84 -0
  5. ggh4x/_datasets.py +106 -0
  6. ggh4x/_download.py +111 -0
  7. ggh4x/_facet_helpers.py +313 -0
  8. ggh4x/_facet_utils.py +649 -0
  9. ggh4x/_gap_grobs.py +606 -0
  10. ggh4x/_registry.py +10 -0
  11. ggh4x/_rlang.py +93 -0
  12. ggh4x/_utils.py +150 -0
  13. ggh4x/_vctrs.py +233 -0
  14. ggh4x/conveniences.py +601 -0
  15. ggh4x/coord_axes_inside.py +380 -0
  16. ggh4x/element_part_rect.py +545 -0
  17. ggh4x/facet_grid2.py +1018 -0
  18. ggh4x/facet_manual.py +901 -0
  19. ggh4x/facet_nested.py +776 -0
  20. ggh4x/facet_nested_wrap.py +193 -0
  21. ggh4x/facet_wrap2.py +896 -0
  22. ggh4x/geom_box.py +536 -0
  23. ggh4x/geom_outline_point.py +444 -0
  24. ggh4x/geom_pointpath.py +259 -0
  25. ggh4x/geom_polygonraster.py +252 -0
  26. ggh4x/geom_rectrug.py +489 -0
  27. ggh4x/geom_text_aimed.py +279 -0
  28. ggh4x/guide_stringlegend.py +354 -0
  29. ggh4x/help_secondary.py +549 -0
  30. ggh4x/multiscale/__init__.py +51 -0
  31. ggh4x/multiscale/_multiscale_add.py +207 -0
  32. ggh4x/multiscale/scale_listed.py +167 -0
  33. ggh4x/multiscale/scale_manual.py +478 -0
  34. ggh4x/multiscale/scale_multi.py +393 -0
  35. ggh4x/panel_scales/__init__.py +58 -0
  36. ggh4x/panel_scales/at_panel.py +115 -0
  37. ggh4x/panel_scales/facetted_pos_scales.py +647 -0
  38. ggh4x/panel_scales/force_panelsize.py +411 -0
  39. ggh4x/panel_scales/scale_facet.py +222 -0
  40. ggh4x/position_disjoint_ranges.py +229 -0
  41. ggh4x/position_lineartrans.py +242 -0
  42. ggh4x/py.typed +0 -0
  43. ggh4x/resources/faithful.csv +273 -0
  44. ggh4x/resources/iris.csv +151 -0
  45. ggh4x/resources/mtcars.csv +33 -0
  46. ggh4x/resources/pressure.csv +20 -0
  47. ggh4x/resources/volcano.csv +87 -0
  48. ggh4x/save.py +255 -0
  49. ggh4x/stat_difference.py +388 -0
  50. ggh4x/stat_funxy.py +436 -0
  51. ggh4x/stat_rle.py +290 -0
  52. ggh4x/stat_rollingkernel.py +369 -0
  53. ggh4x/stat_theodensity.py +681 -0
  54. ggh4x/strip_nested.py +448 -0
  55. ggh4x/strip_split.py +687 -0
  56. ggh4x/strip_tag.py +636 -0
  57. ggh4x/strip_themed.py +232 -0
  58. ggh4x/strip_vanilla.py +1464 -0
  59. ggh4x/themes.py +31 -0
  60. ggh4x/themes_ggh4x.py +67 -0
  61. ggh4x_python-0.3.1.9000.dist-info/METADATA +40 -0
  62. ggh4x_python-0.3.1.9000.dist-info/RECORD +64 -0
  63. ggh4x_python-0.3.1.9000.dist-info/WHEEL +4 -0
  64. 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