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_tag.py ADDED
@@ -0,0 +1,636 @@
1
+ """Tag strips for ggh4x facets (port of ggh4x ``strip_tag.R``).
2
+
3
+ This module ports :class:`StripTag` and the :func:`strip_tag` constructor. Tag
4
+ strips render the strips as fitted text boxes *inside* the panels (anchored to a
5
+ panel corner via a justified viewport), rather than as full-width strips outside
6
+ the panels.
7
+
8
+ ``StripTag`` extends :class:`ggh4x.strip_themed.StripThemed` (a *sibling* of
9
+ ``StripNested``, not a subclass). It overrides:
10
+
11
+ * :meth:`StripTag.setup` -- builds *per-panel* (not de-duplicated) col/row var
12
+ frames; reuses the base ``get_strips`` x / y shape.
13
+ * :meth:`StripTag.draw_labels` -- a *self-less* fitted-box label builder: no
14
+ margin-equalisation, no ``unit(1, "null")`` cross-axis; measures grob
15
+ height/width in cm (per-layer max on the strip axis, exact cm on the cross
16
+ axis).
17
+ * :meth:`StripTag.finish_strip` -- *has* ``self`` (unlike the base self-less
18
+ ``finish_strip``): builds fitted-box gtables with npc-fractional widths inside
19
+ a cm outer viewport justified to a panel corner.
20
+ * :meth:`StripTag.incorporate_grid` / :meth:`StripTag.incorporate_wrap` -- place
21
+ the tag grobs *onto* existing panel cells (no new rows/cols).
22
+
23
+ R source: ``ggh4x/R/strip_tag.R``.
24
+
25
+ Notes
26
+ -----
27
+ * **Viewport npc trick.** Each fitted box is a ``gtable_matrix`` whose
28
+ widths/heights are ``unit(w / sum(w), "npc")`` wrapped in a viewport sized to
29
+ ``unit(sum(w), "cm")`` and justified to ``params["just"]``. When ``clip ==
30
+ "on"`` the viewport is clamped to ``unit(1, "npc")`` via :func:`unit_pmin`.
31
+ * **grid layout combine.** ``incorporate_grid`` ``rbind``s the x and y tag
32
+ gtables (order from ``params["order"]``), recomputing the combined viewport
33
+ height (sum) and width (max).
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from typing import Any, Dict, List, Optional, Sequence
39
+
40
+ import numpy as np
41
+ import pandas as pd
42
+
43
+ from ggplot2_py import element_grob
44
+ from ggplot2_py import is_theme_element as _is_theme_element
45
+ from ggplot2_py._utils import height_cm, width_cm
46
+ from ggplot2_py.ggproto import ggproto
47
+ from grid_py import (
48
+ Unit,
49
+ Viewport,
50
+ edit_grob,
51
+ grob_height,
52
+ grob_name,
53
+ grob_tree,
54
+ grob_width,
55
+ unit_c,
56
+ unit_pmin,
57
+ )
58
+ from gtable_py import gtable_add_grob, gtable_matrix, rbind_gtable
59
+
60
+ from ggh4x._rlang import arg_match0
61
+ from ggh4x.strip_themed import StripThemed
62
+ from ggh4x.strip_vanilla import (
63
+ _LabelGrobs,
64
+ _is_zero_grob,
65
+ _panel_layout,
66
+ _split_by,
67
+ validate_element_list,
68
+ )
69
+
70
+ __all__ = ["StripTag", "strip_tag"]
71
+
72
+
73
+ def _draw_labels_tag(
74
+ labels: Sequence[str],
75
+ element: Dict[str, List[Any]],
76
+ position: str,
77
+ layer_id: Sequence[int],
78
+ size: Any = None,
79
+ ) -> _LabelGrobs:
80
+ """Build fitted-box label grobs (self-less; tag variant of ``draw_labels``).
81
+
82
+ Port of R ``StripTag$draw_labels`` (``strip_tag.R:119-163``). Unlike the
83
+ base, there is no margin-equalisation and no ``unit(1, "null")`` cross axis:
84
+ label sizes are measured directly in cm with :func:`grid_py.grob_height` /
85
+ :func:`grid_py.grob_width` (per-layer max on the strip axis, exact cm on the
86
+ cross axis).
87
+
88
+ Parameters
89
+ ----------
90
+ labels : sequence of str
91
+ Flattened (column-major) label strings, one per cell.
92
+ element : dict
93
+ ``{"el": [...text elements...], "bg": [...background grobs...]}``.
94
+ position : str
95
+ Strip side.
96
+ layer_id : sequence of int
97
+ 1-based layer id per cell.
98
+ size : Any, optional
99
+ Unused (kept for signature parity with the base ``draw_labels``).
100
+
101
+ Returns
102
+ -------
103
+ _LabelGrobs
104
+ A list of GTree grobs with ``.width`` / ``.height`` unit attributes.
105
+ """
106
+ aes = "x" if position in ("top", "bottom") else "y"
107
+ layer_id = list(layer_id)
108
+
109
+ grobs: List[Any] = []
110
+ for label, elem in zip(labels, element["el"]):
111
+ grob = element_grob(elem, label=label, margin_x=True, margin_y=True)
112
+ try:
113
+ grob.name = grob_name(grob, "strip.text." + aes)
114
+ except (AttributeError, TypeError):
115
+ pass
116
+ grobs.append(grob)
117
+
118
+ zeros = [_is_zero_grob(g) for g in grobs]
119
+ if len(grobs) == 0 or all(zeros):
120
+ return _LabelGrobs(grobs)
121
+
122
+ nonzero_idx = [i for i, z in enumerate(zeros) if not z]
123
+ nonzero_layer = [layer_id[i] for i in nonzero_idx]
124
+
125
+ heights = [grob_height(grobs[i]) for i in nonzero_idx]
126
+ widths = [grob_width(grobs[i]) for i in nonzero_idx]
127
+
128
+ if aes == "x":
129
+ # per-layer max height (of grobHeight units); exact cm width.
130
+ grouped = _split_by(heights, nonzero_layer)
131
+ height_units = [_max_unit(g) for g in grouped]
132
+ height = unit_c(*height_units) if len(height_units) > 1 else height_units[0]
133
+ width = Unit(_to_list(width_cm(widths)), "cm")
134
+ else:
135
+ # per-layer max width (of grobWidth units); exact cm height.
136
+ grouped = _split_by(widths, nonzero_layer)
137
+ width_units = [_max_unit(g) for g in grouped]
138
+ width = unit_c(*width_units) if len(width_units) > 1 else width_units[0]
139
+ height = Unit(_to_list(height_cm(heights)), "cm")
140
+
141
+ combined: List[Any] = []
142
+ for x, bg in zip(grobs, element["bg"]):
143
+ bg_grob = element_grob(bg) if _is_theme_element(bg) else bg
144
+ tree = grob_tree(bg_grob, x)
145
+ try:
146
+ tree.name = grob_name(tree, "strip")
147
+ except (AttributeError, TypeError):
148
+ pass
149
+ combined.append(tree)
150
+
151
+ result = _LabelGrobs(combined)
152
+ result.width = width
153
+ result.height = height
154
+ return result
155
+
156
+
157
+ def _max_unit(units: Sequence[Any]) -> Any:
158
+ """Port of R ``max_height`` / ``max_width`` applied to a list of *units*.
159
+
160
+ For the tag path the per-layer reduction is over already-measured unit
161
+ objects (``grobHeight`` / ``grobWidth`` results), so it reduces to the
162
+ element-wise maximum cm value wrapped back as a single ``unit``.
163
+
164
+ Parameters
165
+ ----------
166
+ units : sequence of grid_py.Unit
167
+ The per-cell measured units for one layer.
168
+
169
+ Returns
170
+ -------
171
+ grid_py.Unit
172
+ A single ``cm`` unit holding the maximum measured size.
173
+ """
174
+ if len(units) == 0:
175
+ return Unit(0.0, "cm")
176
+ values = [float(np.atleast_1d(width_cm(u))[0]) for u in units]
177
+ return Unit(max(values), "cm")
178
+
179
+
180
+ class StripTag(StripThemed):
181
+ """Strip rendered as a fitted text box inside the panels.
182
+
183
+ Subclass of :class:`ggh4x.strip_themed.StripThemed`. See the module
184
+ docstring for the fitted-box / viewport algorithm.
185
+
186
+ Attributes
187
+ ----------
188
+ params : dict
189
+ Holds ``clip``, ``order`` (``["x", "y"]`` or ``["y", "x"]``) and ``just``
190
+ (a length-2 numeric justification).
191
+ """
192
+
193
+ _class_name = "StripTag"
194
+
195
+ # self-less draw_labels (first param is not `self`).
196
+ draw_labels = staticmethod(_draw_labels_tag)
197
+
198
+ def setup(
199
+ self,
200
+ layout: pd.DataFrame,
201
+ params: Dict[str, Any],
202
+ theme: Any,
203
+ type: str,
204
+ ) -> None:
205
+ """Build per-panel (non-de-duplicated) var frames and get strips.
206
+
207
+ Port of R ``StripTag$setup`` (``strip_tag.R:92-117``). Unlike the base,
208
+ tags are per-panel (they overlap panels), so the layout is *not*
209
+ de-duplicated.
210
+
211
+ Parameters
212
+ ----------
213
+ layout : pandas.DataFrame
214
+ The facet layout.
215
+ params : dict
216
+ Facet params (``facets`` for wrap; ``rows`` / ``cols`` for grid; plus
217
+ ``labeller``).
218
+ theme : Theme
219
+ The active theme.
220
+ type : str
221
+ ``"wrap"`` or ``"grid"``.
222
+ """
223
+ self._set(elements=self.setup_elements(theme, type))
224
+
225
+ if type == "wrap":
226
+ facets = params.get("facets") or {}
227
+ facet_names = list(facets.keys()) if hasattr(facets, "keys") else list(facets)
228
+ if len(facet_names) == 0:
229
+ labels = pd.DataFrame({"(all)": ["(all)"]})
230
+ else:
231
+ labels = layout[facet_names].reset_index(drop=True)
232
+ col_vars = labels
233
+ row_vars = labels
234
+ else:
235
+ col_names = _names(params.get("cols"))
236
+ row_names = _names(params.get("rows"))
237
+ col_vars = layout[col_names].reset_index(drop=True) if col_names else _empty_frame(layout)
238
+ row_vars = layout[row_names].reset_index(drop=True) if row_names else _empty_frame(layout)
239
+
240
+ self.get_strips(
241
+ x=col_vars,
242
+ y=row_vars,
243
+ labeller=params.get("labeller"),
244
+ theme=theme,
245
+ params=self.params,
246
+ layout_x=layout,
247
+ layout_y=layout,
248
+ )
249
+
250
+ def finish_strip( # type: ignore[override]
251
+ self,
252
+ strip: Sequence[Any],
253
+ width: Any,
254
+ height: Any,
255
+ position: str,
256
+ layout: pd.DataFrame,
257
+ dim: Any,
258
+ clip: str = "inherit",
259
+ ) -> pd.DataFrame:
260
+ """Build fitted-box gtables placed inside panels via a justified vp.
261
+
262
+ Port of R ``StripTag$finish_strip`` (``strip_tag.R:165-217``). Has
263
+ ``self`` (unlike the base self-less ``finish_strip``) to read
264
+ ``self.params["just"]``.
265
+
266
+ Parameters
267
+ ----------
268
+ strip : sequence
269
+ The label grobs (one per cell).
270
+ width, height : grid_py.Unit
271
+ Per-cell width / height unit vectors (measured cm).
272
+ position : str
273
+ Strip side.
274
+ layout : pandas.DataFrame
275
+ The (full, per-panel) layout carrying ``PANEL``.
276
+ dim : tuple of int
277
+ ``(nrow, ncol)`` of the label matrix (panels x layers).
278
+ clip : str, default ``"inherit"``
279
+ Clip setting (``"on"`` clamps the box to ``1 npc``).
280
+
281
+ Returns
282
+ -------
283
+ pandas.DataFrame
284
+ Placement frame with ``t = l = b = r = PANEL`` and a ``grobs`` column
285
+ of fitted-box gtables.
286
+ """
287
+ strip = list(strip)
288
+ empty = len(strip) == 0 or all(_is_zero_grob(g) for g in strip)
289
+
290
+ out_grobs: List[Any] = strip
291
+ if not empty:
292
+ just = self.params["just"]
293
+ n = len(strip)
294
+ w_cm = _recycle(_to_list(width_cm(width)), n)
295
+ h_cm = _recycle(_to_list(height_cm(height)), n)
296
+
297
+ nrow, ncol = int(dim[0]), int(dim[1])
298
+ # idx = matrix(seq_along(strip), nrow, ncol) -- column-major.
299
+ idx = np.arange(n, dtype=int).reshape(nrow, ncol, order="F")
300
+
301
+ out_grobs = []
302
+ for ri in range(nrow):
303
+ if position in ("top", "bottom"):
304
+ # apply(idx, 1, matrix, ncol=1) -> column vector (ncol x 1).
305
+ sub_idx = idx[ri, :].reshape(ncol, 1, order="F")
306
+ else:
307
+ # apply(idx, 1, matrix, nrow=1) -> row vector (1 x ncol).
308
+ sub_idx = idx[ri, :].reshape(1, ncol, order="F")
309
+ d0, d1 = sub_idx.shape
310
+ flat = list(sub_idx.reshape(-1, order="F"))
311
+ m = [[strip[sub_idx[i, j]] for j in range(d1)] for i in range(d0)]
312
+ wmat = np.array([w_cm[k] for k in flat], dtype=float).reshape(d0, d1, order="F")
313
+ hmat = np.array([h_cm[k] for k in flat], dtype=float).reshape(d0, d1, order="F")
314
+ w = wmat.max(axis=0) # per column (layer)
315
+ h = hmat.max(axis=1) # per row
316
+ sum_w = float(w.sum())
317
+ sum_h = float(h.sum())
318
+
319
+ vp_width = Unit(sum_w, "cm")
320
+ vp_height = Unit(sum_h, "cm")
321
+ if clip == "on":
322
+ vp_width = unit_pmin(vp_width, Unit(1, "npc"))
323
+ vp_height = unit_pmin(vp_height, Unit(1, "npc"))
324
+
325
+ vp = Viewport(
326
+ x=just[0],
327
+ y=just[1],
328
+ just=list(just),
329
+ width=vp_width,
330
+ height=vp_height,
331
+ clip=clip,
332
+ )
333
+ widths = _npc_unit(w, sum_w)
334
+ heights = _npc_unit(h, sum_h)
335
+ # Build the fitted-box gtable then attach the justified viewport.
336
+ # (gtable_py's ``Gtable(vp=...)`` reconstruction reads
337
+ # ``vp.justification`` which grid_py stores as ``vp.just``, so the
338
+ # viewport is attached post-construction rather than via the
339
+ # ``vp=`` kwarg -- functionally identical to R's
340
+ # ``gtable_matrix(..., vp = vp)``.)
341
+ gt = gtable_matrix("strip-cells", m, widths, heights, clip=clip)
342
+ gt.vp = vp
343
+ out_grobs.append(gt)
344
+
345
+ panel = [int(p) for p in layout["PANEL"]]
346
+ return pd.DataFrame(
347
+ {"t": panel, "l": panel, "b": panel, "r": panel, "grobs": out_grobs}
348
+ )
349
+
350
+ def incorporate_wrap(
351
+ self,
352
+ panels: Any,
353
+ position: str,
354
+ clip: str = "off",
355
+ sizes: Optional[Dict[str, Any]] = None,
356
+ ) -> Any:
357
+ """Place per-panel tag grobs onto wrapped panel cells (no new rows/cols).
358
+
359
+ Port of R ``StripTag$incorporate_wrap`` (``strip_tag.R:219-230``).
360
+
361
+ Parameters
362
+ ----------
363
+ panels : Gtable
364
+ The assembled panel gtable.
365
+ position : str
366
+ One of ``top`` / ``bottom`` / ``left`` / ``right`` -- selects the
367
+ strip side from ``self.strips``.
368
+ clip : str, default ``"off"``
369
+ Clip setting forwarded to ``gtable_add_grob``.
370
+ sizes : dict, optional
371
+ Unused.
372
+
373
+ Returns
374
+ -------
375
+ Gtable
376
+ The panel gtable with this side's tags added.
377
+ """
378
+ strip = _flatten_strips(self.strips)[position]
379
+ if strip is None:
380
+ return panels
381
+ pos = _panel_layout(panels)
382
+ tlist = [pos["t"][ti] for ti in (int(v) for v in strip["t"])]
383
+ llist = [pos["l"][li] for li in (int(v) for v in strip["l"])]
384
+ names = ["strip-" + str(i + 1) for i in range(len(strip))]
385
+ panels = gtable_add_grob(
386
+ panels,
387
+ list(strip["grobs"]),
388
+ name=names,
389
+ t=tlist,
390
+ l=llist,
391
+ clip=clip,
392
+ )
393
+ return panels
394
+
395
+ def incorporate_grid(self, panels: Any, switch: Any) -> Any:
396
+ """Combine x + y tags and place them onto panel cells.
397
+
398
+ Port of R ``StripTag$incorporate_grid`` (``strip_tag.R:232-271``).
399
+ Combines the x and y tag gtables by ``rbind`` (order from
400
+ ``params["order"]``), recomputes the combined viewport height (sum) /
401
+ width (max), then adds the grobs at the panel cell ``t`` / ``l`` with
402
+ ``z=2``, ``clip="on"``.
403
+
404
+ Parameters
405
+ ----------
406
+ panels : Gtable
407
+ The assembled panel gtable.
408
+ switch : str or None
409
+ ``"x"`` / ``"y"`` / ``"both"`` / ``None``.
410
+
411
+ Returns
412
+ -------
413
+ Gtable
414
+ The panel gtable with tags added.
415
+ """
416
+ flat = _flatten_strips(self.strips)
417
+ xstrip = flat["bottom"] if switch in ("x", "both") else flat["top"]
418
+ ystrip = flat["right"] if switch in ("y", "both") else flat["left"]
419
+
420
+ if xstrip is None and ystrip is None:
421
+ return panels
422
+ elif xstrip is None:
423
+ strip = list(ystrip["grobs"])
424
+ elif ystrip is None:
425
+ strip = list(xstrip["grobs"])
426
+ else:
427
+ reorder = list(self.params["order"]) != ["x", "y"]
428
+ strip = []
429
+ for x, y in zip(list(xstrip["grobs"]), list(ystrip["grobs"])):
430
+ vp = getattr(x, "vp", None)
431
+ if vp is not None:
432
+ # R mutates vp$height/$width; grid_py viewports are immutable
433
+ # for these props, so rebuild one with the combined size.
434
+ vp = _rebuild_vp(
435
+ vp,
436
+ height=_sum_units(x.heights, y.heights),
437
+ width=_max_units(x.widths, y.widths),
438
+ )
439
+ new = rbind_gtable(y, x) if reorder else rbind_gtable(x, y)
440
+ if vp is not None:
441
+ new = edit_grob(new, vp=vp)
442
+ strip.append(new)
443
+
444
+ pos = _panel_layout(panels)
445
+ t_src = xstrip if xstrip is not None else ystrip
446
+ l_src = xstrip if xstrip is not None else ystrip
447
+ tlist = [pos["t"][ti] for ti in (int(v) for v in t_src["t"])]
448
+ llist = [pos["l"][li] for li in (int(v) for v in l_src["l"])]
449
+ names = ["strip-" + str(i + 1) for i in range(len(strip))]
450
+ panels = gtable_add_grob(
451
+ panels, strip, name=names, t=tlist, l=llist, clip="on", z=2
452
+ )
453
+ return panels
454
+
455
+
456
+ # ---------------------------------------------------------------------------
457
+ # Helpers
458
+ # ---------------------------------------------------------------------------
459
+ def _names(param: Any) -> List[str]:
460
+ """Return variable names from a facet ``rows`` / ``cols`` param."""
461
+ if param is None:
462
+ return []
463
+ if hasattr(param, "keys"):
464
+ return list(param.keys())
465
+ if isinstance(param, (list, tuple)):
466
+ return [str(p) for p in param]
467
+ return []
468
+
469
+
470
+ def _empty_frame(df: pd.DataFrame) -> pd.DataFrame:
471
+ """Return a 0-column frame with the same row count as *df*."""
472
+ return pd.DataFrame(index=df.index)
473
+
474
+
475
+ def _flatten_strips(strips: Dict[str, Any]) -> Dict[str, Any]:
476
+ """Port of R ``unlist(unname(self$strips), recursive=FALSE)`` (side-keyed)."""
477
+ flat: Dict[str, Any] = {}
478
+ for outer in strips.values():
479
+ for key, value in outer.items():
480
+ flat[key] = value
481
+ return flat
482
+
483
+
484
+ def _recycle(seq: Sequence[Any], length_out: int) -> List[Any]:
485
+ """Port of R ``rep(seq, length.out=n)`` for a plain list."""
486
+ n = len(seq)
487
+ if length_out <= 0 or n == 0:
488
+ return []
489
+ return [seq[i % n] for i in range(length_out)]
490
+
491
+
492
+ def _to_list(x: Any) -> List[float]:
493
+ """Coerce a width_cm / height_cm result to a flat list of floats."""
494
+ arr = np.atleast_1d(np.asarray(x, dtype=float))
495
+ return [float(v) for v in arr]
496
+
497
+
498
+ def _npc_unit(values: np.ndarray, total: float) -> Any:
499
+ """Build a ``unit(values / total, "npc")`` vector.
500
+
501
+ Parameters
502
+ ----------
503
+ values : numpy.ndarray
504
+ The per-cell cm sizes.
505
+ total : float
506
+ The summed cm size (npc denominator).
507
+
508
+ Returns
509
+ -------
510
+ grid_py.Unit
511
+ The npc-fraction unit vector.
512
+ """
513
+ if total == 0:
514
+ fractions = [0.0 for _ in values]
515
+ else:
516
+ fractions = [float(v) / total for v in values]
517
+ return Unit(fractions, "npc")
518
+
519
+
520
+ def _rebuild_vp(vp: Any, height: Any, width: Any) -> Any:
521
+ """Rebuild a viewport with new *height* / *width*, preserving other props.
522
+
523
+ R mutates ``vp$height`` / ``vp$width`` in place; grid_py viewports expose
524
+ those as read-only properties, so this constructs a fresh
525
+ :class:`grid_py.Viewport` carrying the original anchor / justification /
526
+ clip and the new size.
527
+
528
+ Parameters
529
+ ----------
530
+ vp : grid_py.Viewport
531
+ The source viewport (from the x strip's fitted box).
532
+ height : grid_py.Unit
533
+ The new (combined, summed) height.
534
+ width : grid_py.Unit
535
+ The new (combined, maximum) width.
536
+
537
+ Returns
538
+ -------
539
+ grid_py.Viewport
540
+ A new viewport with the combined size.
541
+ """
542
+ just = getattr(vp, "just", None)
543
+ return Viewport(
544
+ x=getattr(vp, "x", None),
545
+ y=getattr(vp, "y", None),
546
+ just=list(just) if just is not None else "centre",
547
+ width=width,
548
+ height=height,
549
+ clip=getattr(vp, "clip", "inherit"),
550
+ )
551
+
552
+
553
+ def _sum_units(a: Any, b: Any) -> Any:
554
+ """Port of R ``sum(a, b)`` over two unit vectors (cm total)."""
555
+ av = float(np.sum(np.atleast_1d(np.asarray(height_cm(a), dtype=float))))
556
+ bv = float(np.sum(np.atleast_1d(np.asarray(height_cm(b), dtype=float))))
557
+ return Unit(av + bv, "cm")
558
+
559
+
560
+ def _max_units(a: Any, b: Any) -> Any:
561
+ """Port of R ``max(a, b)`` over two unit vectors (cm max)."""
562
+ av = float(np.max(np.atleast_1d(np.asarray(width_cm(a), dtype=float))))
563
+ bv = float(np.max(np.atleast_1d(np.asarray(width_cm(b), dtype=float))))
564
+ return Unit(max(av, bv), "cm")
565
+
566
+
567
+ # R's ``StripTag`` ggproto instance used as the parent of every clone.
568
+ _STRIP_TAG_SINGLETON: "StripTag" = StripTag()
569
+
570
+
571
+ def strip_tag(
572
+ clip: str = "inherit",
573
+ order: Any = ("x", "y"),
574
+ just: Any = (0, 1),
575
+ text_x: Any = None,
576
+ text_y: Any = None,
577
+ background_x: Any = None,
578
+ background_y: Any = None,
579
+ by_layer_x: bool = False,
580
+ by_layer_y: bool = False,
581
+ ) -> StripTag:
582
+ """Create a tag strip (fitted text boxes inside the panels).
583
+
584
+ Port of R ``strip_tag()`` (``strip_tag.R:53-85``).
585
+
586
+ Parameters
587
+ ----------
588
+ clip : str, default ``"inherit"``
589
+ Whether labels are clipped to background boxes.
590
+ order : sequence of str, default ``("x", "y")``
591
+ Either ``("x", "y")`` or ``("y", "x")`` -- the top-to-bottom order of
592
+ horizontal vs "vertical" labels in a grid layout.
593
+ just : sequence of float, default ``(0, 1)``
594
+ Horizontal and vertical justification for the text box anchor.
595
+ text_x, text_y, background_x, background_y : list or element or None
596
+ Per-strip themed elements. R defaults ``text_y`` to
597
+ ``element_text(angle = 0)``; pass an explicit value to override.
598
+ by_layer_x, by_layer_y : bool, default ``False``
599
+ Map elements to layers (``True``) or strips (``False``).
600
+
601
+ Returns
602
+ -------
603
+ StripTag
604
+ A ``StripTag`` ggproto instance usable in ggh4x facets.
605
+ """
606
+ if text_y is None:
607
+ # R default: text_y = element_text(angle = 0).
608
+ from ggplot2_py import element_text
609
+
610
+ text_y = element_text(angle=0)
611
+
612
+ params = {
613
+ "clip": arg_match0(clip, ["on", "off", "inherit"], arg_name="clip"),
614
+ "order": list(order),
615
+ "just": list(just),
616
+ # R's ``strip_tag()`` params has no ``size`` (``params$size`` is NULL and
617
+ # the tag's own ``draw_labels`` ignores it). The base ``assemble_strip``
618
+ # reads ``params["size"]`` with subscript access, so a behaviour-neutral
619
+ # ``"constant"`` is supplied here (consumed only as the always-ignored
620
+ # ``size`` argument of ``StripTag.draw_labels``). Documented deviation.
621
+ "size": "constant",
622
+ }
623
+ given_elements = {
624
+ "text_x": validate_element_list(text_x, "element_text"),
625
+ "text_y": validate_element_list(text_y, "element_text"),
626
+ "background_x": validate_element_list(background_x, "element_rect"),
627
+ "background_y": validate_element_list(background_y, "element_rect"),
628
+ "by_layer_x": bool(by_layer_x),
629
+ "by_layer_y": bool(by_layer_y),
630
+ }
631
+ return ggproto(
632
+ None,
633
+ _STRIP_TAG_SINGLETON,
634
+ params=params,
635
+ given_elements=given_elements,
636
+ )