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/facet_wrap2.py ADDED
@@ -0,0 +1,896 @@
1
+ """Extended wrapped facets (port of ggh4x ``R/facet_wrap2.R``).
2
+
3
+ ``facet_wrap2`` behaves like :func:`ggplot2_py.facet_wrap` but adds inner-axis
4
+ drawing (``axes``), inner-axis label removal (``remove_labels``) and a literal
5
+ ``trim_blank=False`` layout (``nrow`` / ``ncol`` taken verbatim).
6
+
7
+ The :class:`FacetWrap2` ggproto subclasses :class:`ggplot2_py.facet.FacetWrap` and
8
+ *fully replaces* ``draw_panels`` with a decomposed, strip-pluggable pipeline. Its
9
+ ``setup_axes`` is substantially larger than ``FacetGrid2``'s: it masks interior
10
+ axes, measures them after deletion, then re-places marginal axes bordering empty
11
+ cells so dangling panels keep their axis.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Dict, List, Optional, Sequence
17
+
18
+ import numpy as np
19
+ import pandas as pd
20
+
21
+ from ggplot2_py import calc_element, ggproto
22
+ from ggplot2_py.coord import CoordFlip
23
+ from ggplot2_py.facet import FacetWrap, _resolve_facet_vars, facet_wrap
24
+ from grid_py import GList, GTree, Unit, null_grob
25
+ from gtable_py import (
26
+ Gtable,
27
+ gtable_add_col_space,
28
+ gtable_add_grob,
29
+ gtable_add_row_space,
30
+ )
31
+
32
+ from ggh4x._borrowed_ggplot2 import is_zero, snake_class
33
+ from ggh4x._cli import cli_abort, cli_warn
34
+ from ggh4x._facet_helpers import AspectRatio, _match_facet_arg
35
+ from ggh4x._facet_utils import render_axes, weave_tables_col, weave_tables_row
36
+ from ggh4x.facet_grid2 import _decorate_panels, _measure_axes, purge_guide_labels
37
+ from ggh4x.strip_vanilla import resolve_strip
38
+
39
+ __all__ = [
40
+ "facet_wrap2",
41
+ "FacetWrap2",
42
+ "new_wrap_facets",
43
+ "purge_guide_labels",
44
+ "_measure_axes",
45
+ ]
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Constructor
50
+ # ---------------------------------------------------------------------------
51
+ def facet_wrap2(
52
+ facets: Any,
53
+ nrow: Optional[int] = None,
54
+ ncol: Optional[int] = None,
55
+ scales: Any = "fixed",
56
+ axes: Any = "margins",
57
+ remove_labels: Any = "none",
58
+ shrink: bool = True,
59
+ labeller: Any = "label_value",
60
+ as_table: bool = True,
61
+ drop: bool = True,
62
+ dir: str = "h",
63
+ strip_position: str = "top",
64
+ trim_blank: bool = True,
65
+ strip: Any = "vanilla",
66
+ ) -> "FacetWrap2":
67
+ """Extended wrapped facets.
68
+
69
+ Port of ggh4x's ``facet_wrap2()`` (``R/facet_wrap2.R:67-86``). Like
70
+ :func:`ggplot2_py.facet_wrap` but can draw / label-purge inner axes when
71
+ scales are fixed, and can honour a literal ``nrow``/``ncol`` via
72
+ ``trim_blank=False``.
73
+
74
+ Parameters
75
+ ----------
76
+ facets : formula / list / dict
77
+ Faceting variables.
78
+ nrow, ncol : int or None
79
+ scales : {"fixed", "free_x", "free_y", "free"} or bool, default "fixed"
80
+ axes : {"margins", "x", "y", "all"} or bool, default "margins"
81
+ remove_labels : {"none", "x", "y", "all"} or bool, default "none"
82
+ shrink : bool, default True
83
+ labeller : callable or str, default "label_value"
84
+ as_table : bool, default True
85
+ drop : bool, default True
86
+ dir : {"h", "v"}, default "h"
87
+ strip_position : {"top", "bottom", "left", "right"}, default "top"
88
+ trim_blank : bool, default True
89
+ When ``False``, ``nrow``/``ncol`` are taken literally.
90
+ strip : Strip or callable or str, default "vanilla"
91
+
92
+ Returns
93
+ -------
94
+ FacetWrap2
95
+ """
96
+ return new_wrap_facets(
97
+ facets,
98
+ nrow,
99
+ ncol,
100
+ scales,
101
+ axes,
102
+ remove_labels,
103
+ shrink,
104
+ labeller,
105
+ as_table,
106
+ drop,
107
+ dir,
108
+ strip_position,
109
+ strip,
110
+ trim_blank,
111
+ super_=FacetWrap2,
112
+ )
113
+
114
+
115
+ def new_wrap_facets(
116
+ facets: Any,
117
+ nrow: Optional[int],
118
+ ncol: Optional[int],
119
+ scales: Any,
120
+ axes: Any,
121
+ rmlab: Any,
122
+ shrink: bool,
123
+ labeller: Any,
124
+ as_table: bool,
125
+ drop: bool,
126
+ dir: str,
127
+ strip_position: str,
128
+ strip: Any,
129
+ trim_blank: bool,
130
+ params: Optional[Dict[str, Any]] = None,
131
+ super_: Any = None,
132
+ ) -> "FacetWrap2":
133
+ """Build a :class:`FacetWrap2` instance from raw arguments.
134
+
135
+ Port of ggh4x's ``new_wrap_facets()`` (``R/facet_wrap2.R:90-120``). Obtains
136
+ the full prototype param dict from :func:`ggplot2_py.facet_wrap`, normalises
137
+ ``axes`` / ``remove_labels``, resolves the strip, computes the ``dim`` for the
138
+ non-trimmed case and assembles the params.
139
+
140
+ Parameters
141
+ ----------
142
+ facets : Any
143
+ nrow, ncol : int or None
144
+ scales, axes, rmlab : Any
145
+ shrink : bool
146
+ labeller : Any
147
+ as_table : bool
148
+ drop : bool
149
+ dir : str
150
+ strip_position : str
151
+ strip : Any
152
+ trim_blank : bool
153
+ params : dict, optional
154
+ super_ : type, optional
155
+
156
+ Returns
157
+ -------
158
+ FacetWrap2
159
+ """
160
+ if super_ is None:
161
+ super_ = FacetWrap2
162
+ params = dict(params or {})
163
+
164
+ prototype = facet_wrap(
165
+ facets=facets,
166
+ nrow=nrow,
167
+ ncol=ncol,
168
+ scales=scales,
169
+ shrink=shrink,
170
+ labeller=labeller,
171
+ as_table=as_table,
172
+ drop=drop,
173
+ dir=dir,
174
+ strip_position=strip_position,
175
+ ).params
176
+
177
+ axes = _match_facet_arg(axes, ["margins", "x", "y", "all"], nm="axes")
178
+ rmlab = _match_facet_arg(rmlab, ["none", "x", "y", "all"], nm="remove_labels")
179
+ strip = resolve_strip(strip)
180
+
181
+ if trim_blank:
182
+ dim = None
183
+ else:
184
+ dim = [
185
+ prototype.get("nrow") if prototype.get("nrow") is not None else np.nan,
186
+ prototype.get("ncol") if prototype.get("ncol") is not None else np.nan,
187
+ ]
188
+
189
+ merged = dict(prototype)
190
+ merged.update(params)
191
+ # Store ``facets`` as a name list (R keeps a named quosure list; the strip /
192
+ # layout read the names) so the strip subsystem agrees with the layout.
193
+ merged["facets"] = _resolve_facet_vars(prototype.get("facets"))
194
+ merged.update({"dim": dim, "axes": axes, "rmlab": rmlab})
195
+ # Ensure a strip.position alias exists for the R-style param name used in
196
+ # setup_axes warnings / strip incorporation.
197
+ merged.setdefault("strip.position", merged.get("strip_position", strip_position))
198
+
199
+ obj = super_()
200
+ obj._set(shrink=shrink, strip=strip, params=merged)
201
+ return obj
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # ggproto
206
+ # ---------------------------------------------------------------------------
207
+ class FacetWrap2(FacetWrap):
208
+ """Extended wrapped facet ggproto (port of R ``FacetWrap2``).
209
+
210
+ Subclasses :class:`ggplot2_py.facet.FacetWrap`. Replaces ``draw_panels`` with
211
+ a decomposed, strip-pluggable pipeline; all sub-steps are overridable.
212
+
213
+ Attributes
214
+ ----------
215
+ shrink : bool
216
+ strip : Strip
217
+ params : dict
218
+ """
219
+
220
+ _class_name = "FacetWrap2"
221
+
222
+ shrink: bool = True
223
+ strip: Any = None
224
+
225
+ # -- setup_aspect_ratio (identical to FacetGrid2) -----------------------
226
+ def setup_aspect_ratio(
227
+ self,
228
+ coord: Any,
229
+ free: Dict[str, bool],
230
+ theme: Any,
231
+ ranges: Sequence[Any],
232
+ ) -> AspectRatio:
233
+ """Resolve the panel aspect ratio + ``respect`` flag.
234
+
235
+ Port of ggh4x's ``FacetWrap2$setup_aspect_ratio``
236
+ (``R/facet_wrap2.R:134-148``); identical to ``FacetGrid2``'s.
237
+
238
+ Parameters
239
+ ----------
240
+ coord : Coord
241
+ free : dict
242
+ theme : Theme
243
+ ranges : sequence
244
+
245
+ Returns
246
+ -------
247
+ AspectRatio
248
+ """
249
+ aspect_ratio = calc_element("aspect.ratio", theme) if theme is not None else None
250
+ if aspect_ratio is None and not free["x"] and not free["y"] and ranges:
251
+ aspect_ratio = coord.aspect(ranges[0])
252
+ if aspect_ratio is None:
253
+ return AspectRatio(1.0, False)
254
+ return AspectRatio(float(aspect_ratio), True)
255
+
256
+ # -- setup_layout (CoordFlip scale swap) --------------------------------
257
+ def setup_layout(
258
+ self,
259
+ layout: pd.DataFrame,
260
+ coord: Any,
261
+ params: Dict[str, Any],
262
+ ) -> pd.DataFrame:
263
+ """Swap ``SCALE_X`` / ``SCALE_Y`` assignment under ``CoordFlip``.
264
+
265
+ Port of ggh4x's ``FacetWrap2$setup_layout`` (``R/facet_wrap2.R:149-166``).
266
+ A no-op for non-flipped coords; under :class:`ggplot2_py.coord.CoordFlip`
267
+ each free dimension's ``SCALE_*`` becomes per-panel (``seq_len(nrow)``).
268
+
269
+ Parameters
270
+ ----------
271
+ layout : pandas.DataFrame
272
+ coord : Coord
273
+ params : dict
274
+
275
+ Returns
276
+ -------
277
+ pandas.DataFrame
278
+ """
279
+ if isinstance(coord, CoordFlip):
280
+ layout = layout.copy()
281
+ n = len(layout)
282
+ layout["SCALE_X"] = np.arange(1, n + 1) if params["free"]["x"] else 1
283
+ layout["SCALE_Y"] = np.arange(1, n + 1) if params["free"]["y"] else 1
284
+ return layout
285
+
286
+ # -- setup_panel_table (self-first R signature) -------------------------
287
+ def setup_panel_table(
288
+ self,
289
+ panels: List[Any],
290
+ layout: pd.DataFrame,
291
+ theme: Any,
292
+ coord: Any,
293
+ ranges: Sequence[Any],
294
+ params: Dict[str, Any],
295
+ ) -> Gtable:
296
+ """Build the wrap panel gtable (panels may span multiple cells).
297
+
298
+ Port of ggh4x's ``FacetWrap2$setup_panel_table``
299
+ (``R/facet_wrap2.R:167-196``). ``ncol``/``nrow`` come from ``params.dim``
300
+ or the spanning ``.LEFT``/``.RIGHT``/``.TOP``/``.BOTTOM`` columns; panels
301
+ are placed at ``t=.TOP, b=.BOTTOM, l=.LEFT, r=.RIGHT`` (``z=1``) so a
302
+ single panel can span empty cells.
303
+
304
+ Parameters
305
+ ----------
306
+ panels : list of grob
307
+ One decorated panel grob per PANEL.
308
+ layout : pandas.DataFrame
309
+ Carries ``.TOP``/``.BOTTOM``/``.LEFT``/``.RIGHT``.
310
+ theme : Theme
311
+ coord : Coord
312
+ ranges : sequence
313
+ params : dict
314
+
315
+ Returns
316
+ -------
317
+ Gtable
318
+ """
319
+ dim = params.get("dim")
320
+ if dim is not None and not _is_na(dim[1]):
321
+ ncol = int(dim[1])
322
+ else:
323
+ ncol = int(max(layout[".LEFT"].max(), layout[".RIGHT"].max()))
324
+ if dim is not None and not _is_na(dim[0]):
325
+ nrow = int(dim[0])
326
+ else:
327
+ nrow = int(max(layout[".TOP"].max(), layout[".BOTTOM"].max()))
328
+
329
+ aspect = self.setup_aspect_ratio(coord, params["free"], theme, ranges)
330
+
331
+ respect = params.get("respect")
332
+ if respect is None:
333
+ respect = aspect.respect
334
+ widths = params.get("widths")
335
+ if widths is None:
336
+ widths = Unit(1, "null")
337
+ heights = params.get("heights")
338
+ if heights is None:
339
+ heights = Unit(abs(aspect.value), "null")
340
+
341
+ panel_table = Gtable(
342
+ widths=_rep_unit(widths, ncol),
343
+ heights=_rep_unit(heights, nrow),
344
+ respect=respect,
345
+ )
346
+
347
+ t = [int(v) for v in layout[".TOP"]]
348
+ b = [int(v) for v in layout[".BOTTOM"]]
349
+ l = [int(v) for v in layout[".LEFT"]]
350
+ r = [int(v) for v in layout[".RIGHT"]]
351
+ names = [f"panel-{i + 1}" for i in range(len(panels))]
352
+ panel_table = gtable_add_grob(
353
+ panel_table,
354
+ list(panels),
355
+ t=t,
356
+ b=b,
357
+ l=l,
358
+ r=r,
359
+ z=1,
360
+ clip=coord.clip,
361
+ name=names,
362
+ )
363
+ panel_table = gtable_add_col_space(
364
+ panel_table, calc_element("panel.spacing.x", theme)
365
+ )
366
+ panel_table = gtable_add_row_space(
367
+ panel_table, calc_element("panel.spacing.y", theme)
368
+ )
369
+ return panel_table
370
+
371
+ # -- setup_axes (the large one) -----------------------------------------
372
+ def setup_axes(
373
+ self,
374
+ axes: Dict[str, Any],
375
+ layout: pd.DataFrame,
376
+ params: Dict[str, Any],
377
+ theme: Any,
378
+ ) -> Dict[str, Any]:
379
+ """Fill / blank / measure / re-place the axis matrices for wrap facets.
380
+
381
+ Port of ggh4x's ``FacetWrap2$setup_axes`` (``R/facet_wrap2.R:197-335``).
382
+ Fills the four ``nrow x ncol`` grob matrices by ``SCALE_X`` / ``SCALE_Y``
383
+ at ``cbind(ROW, COL)``, blanks interior axes unless they repeat
384
+ (``free`` | ``axes``), purges labels when requested, **measures the axes
385
+ after deletion** (so dangling-panel gaps are not over-sized), then
386
+ re-places marginal axes bordering empty cells using ``diff()`` of the
387
+ ``empties`` mask.
388
+
389
+ Parameters
390
+ ----------
391
+ axes : dict
392
+ Transposed batch axes ``{"x": {"top", "bottom"},
393
+ "y": {"left", "right"}}`` from :func:`render_axes` (indexed by
394
+ ``SCALE_*``).
395
+ layout : pandas.DataFrame
396
+ params : dict
397
+ theme : Theme
398
+
399
+ Returns
400
+ -------
401
+ dict
402
+ ``{"top", "bottom", "left", "right", "measurements"}``.
403
+ """
404
+ nrow = int(layout["ROW"].max())
405
+ ncol = int(layout["COL"].max())
406
+
407
+ rows = [int(v) for v in layout["ROW"]]
408
+ cols = [int(v) for v in layout["COL"]]
409
+ scale_x = [int(v) for v in layout["SCALE_X"]]
410
+ scale_y = [int(v) for v in layout["SCALE_Y"]]
411
+
412
+ # empties[r, c] = TRUE unless a panel occupies (r, c).
413
+ empties = np.ones((nrow, ncol), dtype=bool)
414
+ for rr, cc in zip(rows, cols):
415
+ empties[rr - 1, cc - 1] = False
416
+
417
+ def _new() -> List[List[Any]]:
418
+ return [[null_grob() for _ in range(ncol)] for _ in range(nrow)]
419
+
420
+ top = _new()
421
+ bottom = _new()
422
+ left = _new()
423
+ right = _new()
424
+
425
+ x_top = axes["x"]["top"]
426
+ x_bottom = axes["x"]["bottom"]
427
+ y_left = axes["y"]["left"]
428
+ y_right = axes["y"]["right"]
429
+
430
+ # Fill by SCALE id at each panel's (ROW, COL).
431
+ for i in range(len(rows)):
432
+ r0 = rows[i] - 1
433
+ c0 = cols[i] - 1
434
+ top[r0][c0] = x_top[scale_x[i] - 1]
435
+ bottom[r0][c0] = x_bottom[scale_x[i] - 1]
436
+ left[r0][c0] = y_left[scale_y[i] - 1]
437
+ right[r0][c0] = y_right[scale_y[i] - 1]
438
+
439
+ repeat_x = params["free"]["x"] or params["axes"]["x"]
440
+ repeat_y = params["free"]["y"] or params["axes"]["y"]
441
+
442
+ if not repeat_x:
443
+ for r in range(1, nrow): # top[-1, ]
444
+ for c in range(ncol):
445
+ top[r][c] = null_grob()
446
+ for r in range(nrow - 1): # bottom[-nrow, ]
447
+ for c in range(ncol):
448
+ bottom[r][c] = null_grob()
449
+ if not repeat_y:
450
+ for r in range(nrow): # left[, -1]
451
+ for c in range(1, ncol):
452
+ left[r][c] = null_grob()
453
+ for r in range(nrow): # right[, -ncol]
454
+ for c in range(ncol - 1):
455
+ right[r][c] = null_grob()
456
+
457
+ if params["axes"]["x"] and params["rmlab"]["x"] and not params["free"]["x"]:
458
+ for r in range(1, nrow):
459
+ for c in range(ncol):
460
+ top[r][c] = purge_guide_labels(top[r][c])
461
+ for r in range(nrow - 1):
462
+ for c in range(ncol):
463
+ bottom[r][c] = purge_guide_labels(bottom[r][c])
464
+ if params["axes"]["y"] and params["rmlab"]["y"] and not params["free"]["y"]:
465
+ for r in range(nrow):
466
+ for c in range(1, ncol):
467
+ left[r][c] = purge_guide_labels(left[r][c])
468
+ for r in range(nrow):
469
+ for c in range(ncol - 1):
470
+ right[r][c] = purge_guide_labels(right[r][c])
471
+
472
+ # Measure AFTER deletion, BEFORE re-placement.
473
+ measurements = _measure_axes(
474
+ {"top": top, "bottom": bottom, "left": left, "right": right}
475
+ )
476
+
477
+ if not empties.any():
478
+ return {
479
+ "top": top,
480
+ "bottom": bottom,
481
+ "left": left,
482
+ "right": right,
483
+ "measurements": measurements,
484
+ }
485
+
486
+ inside = (
487
+ (calc_element("strip.placement", theme) if theme is not None else None)
488
+ or "inside"
489
+ ) == "inside"
490
+ strip_pos = params.get("strip.position", params.get("strip_position", "top"))
491
+
492
+ rc_to_panel = {(rows[i], cols[i]): i for i in range(len(rows))}
493
+
494
+ # bottom_empty: per column, c(diff(empties)==1, FALSE) — a panel whose
495
+ # cell below is empty (transition non-empty -> empty going down).
496
+ bottom_empty = _diff_down(empties, target=1, append_last=False)
497
+ if bottom_empty.any():
498
+ replace_warn = _gather_replace(x_bottom, scale=None, empties_pos=bottom_empty,
499
+ rc_to_panel=rc_to_panel, scale_ids=scale_x,
500
+ rows=rows, cols=cols, side_list=x_bottom)
501
+ if (
502
+ strip_pos == "bottom"
503
+ and not inside
504
+ and any(not is_zero(g) for g in replace_warn)
505
+ and not params["free"]["x"]
506
+ ):
507
+ cli_warn(
508
+ 'Suppressing axis rendering when `strip.position = "bottom"` '
509
+ 'and `strip.placement == "outside"`'
510
+ )
511
+ else:
512
+ _place_back(bottom, bottom_empty, x_bottom, scale_x, rc_to_panel,
513
+ rows, cols)
514
+
515
+ # top_empty: per column, c(FALSE, diff(empties)==-1) — a panel whose cell
516
+ # above is empty.
517
+ top_empty = _diff_down(empties, target=-1, append_last=True)
518
+ if top_empty.any():
519
+ replace_warn = _gather_replace(x_top, scale=None, empties_pos=top_empty,
520
+ rc_to_panel=rc_to_panel, scale_ids=scale_x,
521
+ rows=rows, cols=cols, side_list=x_top)
522
+ if (
523
+ strip_pos == "top"
524
+ and not inside
525
+ and any(not is_zero(g) for g in replace_warn)
526
+ and not params["free"]["x"]
527
+ ):
528
+ cli_warn(
529
+ 'Suppressing axis rendering when `strip.position = "top"` '
530
+ 'and `strip.placement == "outside"`'
531
+ )
532
+ else:
533
+ _place_back(top, top_empty, x_top, scale_x, rc_to_panel, rows, cols)
534
+
535
+ # right_empty: per row, c(diff(empties)==1, FALSE).
536
+ right_empty = _diff_right(empties, target=1, append_last=False)
537
+ if right_empty.any():
538
+ replace_warn = _gather_replace(y_right, scale=None, empties_pos=right_empty,
539
+ rc_to_panel=rc_to_panel, scale_ids=scale_y,
540
+ rows=rows, cols=cols, side_list=y_right)
541
+ if (
542
+ strip_pos == "right"
543
+ and not inside
544
+ and any(not is_zero(g) for g in replace_warn)
545
+ and not params["free"]["y"]
546
+ ):
547
+ cli_warn(
548
+ 'Suppressing axis rendering when `strip.position = "right"` '
549
+ 'and `strip.placement == "outside"`'
550
+ )
551
+ # R places back unconditionally for right/left (no else guard).
552
+ _place_back(right, right_empty, y_right, scale_y, rc_to_panel, rows, cols)
553
+
554
+ # left_empty: per row, c(FALSE, diff(empties)==-1).
555
+ left_empty = _diff_right(empties, target=-1, append_last=True)
556
+ if left_empty.any():
557
+ replace_warn = _gather_replace(y_left, scale=None, empties_pos=left_empty,
558
+ rc_to_panel=rc_to_panel, scale_ids=scale_y,
559
+ rows=rows, cols=cols, side_list=y_left)
560
+ if (
561
+ strip_pos == "left"
562
+ and not inside
563
+ and any(not is_zero(g) for g in replace_warn)
564
+ and not params["free"]["y"]
565
+ ):
566
+ cli_warn(
567
+ 'Suppressing axis rendering when `strip.position = "left"` '
568
+ 'and `strip.placement == "outside"`'
569
+ )
570
+ _place_back(left, left_empty, y_left, scale_y, rc_to_panel, rows, cols)
571
+
572
+ return {
573
+ "top": top,
574
+ "bottom": bottom,
575
+ "left": left,
576
+ "right": right,
577
+ "measurements": measurements,
578
+ }
579
+
580
+ # -- attach_axes (explicit sizes) ---------------------------------------
581
+ def attach_axes(
582
+ self,
583
+ panel_table: Gtable,
584
+ axes: Dict[str, Any],
585
+ sizes: Dict[str, Unit],
586
+ ) -> Gtable:
587
+ """Weave the axis bands using pre-computed sizes.
588
+
589
+ Port of ggh4x's ``FacetWrap2$attach_axes`` (``R/facet_wrap2.R:336-346``).
590
+ Differs from ``FacetGrid2`` by taking an explicit ``sizes`` argument (the
591
+ post-deletion measurements) instead of measuring internally.
592
+
593
+ Parameters
594
+ ----------
595
+ panel_table : Gtable
596
+ axes : dict
597
+ ``{"top", "bottom", "left", "right"}`` grob matrices.
598
+ sizes : dict
599
+ ``{"top", "bottom", "left", "right"}`` size unit vectors.
600
+
601
+ Returns
602
+ -------
603
+ Gtable
604
+ """
605
+ panel_table = weave_tables_row(
606
+ panel_table, axes["top"], -1, sizes["top"], "axis-t", 3
607
+ )
608
+ panel_table = weave_tables_row(
609
+ panel_table, axes["bottom"], 0, sizes["bottom"], "axis-b", 3
610
+ )
611
+ panel_table = weave_tables_col(
612
+ panel_table, axes["left"], -1, sizes["left"], "axis-l", 3
613
+ )
614
+ panel_table = weave_tables_col(
615
+ panel_table, axes["right"], 0, sizes["right"], "axis-r", 3
616
+ )
617
+ return panel_table
618
+
619
+ # -- finish_panels (identity seam) --------------------------------------
620
+ def finish_panels(
621
+ self,
622
+ panels: Any,
623
+ layout: pd.DataFrame,
624
+ params: Dict[str, Any],
625
+ theme: Any,
626
+ ) -> Any:
627
+ """Identity post-processing hook (extension seam).
628
+
629
+ Port of ggh4x's ``FacetWrap2$finish_panels`` (``R/facet_wrap2.R:347-349``).
630
+ """
631
+ return panels
632
+
633
+ # -- draw_panels (full override) ----------------------------------------
634
+ def draw_panels(
635
+ self,
636
+ panels: list,
637
+ layout: pd.DataFrame,
638
+ x_scales: list,
639
+ y_scales: list,
640
+ ranges: list,
641
+ coord: Any,
642
+ data: Any,
643
+ theme: Any,
644
+ params: Dict[str, Any],
645
+ ) -> Gtable:
646
+ """Assemble the wrap panel gtable (full replacement of the base pipeline).
647
+
648
+ Port of ggh4x's ``FacetWrap2$draw_panels`` (``R/facet_wrap2.R:350-391``).
649
+ Runs ``setup_layout``; sets the spanning ``.TOP``/``.BOTTOM``/``.LEFT``/
650
+ ``.RIGHT = ROW/COL``; resolves ``params.dim`` NA -> nrow/ncol; then
651
+ ``setup_panel_table`` -> ``render_axes`` -> ``setup_axes`` ->
652
+ ``attach_axes(measurements)`` -> strip ``setup`` / ``incorporate_wrap`` ->
653
+ ``finish_panels``.
654
+
655
+ Parameters
656
+ ----------
657
+ panels : list
658
+ layout : pandas.DataFrame
659
+ x_scales, y_scales : list
660
+ ranges : list
661
+ coord : Coord
662
+ data : Any
663
+ theme : Theme
664
+ params : dict
665
+
666
+ Returns
667
+ -------
668
+ Gtable
669
+ """
670
+ if (params["free"]["x"] or params["free"]["y"]) and not coord.is_free():
671
+ cli_abort(f"`{snake_class(coord)}` doesn't support free scales.")
672
+
673
+ strip = self.strip
674
+ layout = self.setup_layout(layout, coord, params)
675
+
676
+ ncol = int(layout["COL"].max())
677
+ nrow = int(layout["ROW"].max())
678
+ layout = layout.copy()
679
+ layout[".TOP"] = layout["ROW"].to_numpy()
680
+ layout[".BOTTOM"] = layout["ROW"].to_numpy()
681
+ layout[".LEFT"] = layout["COL"].to_numpy()
682
+ layout[".RIGHT"] = layout["COL"].to_numpy()
683
+
684
+ params = dict(params)
685
+ dim = params.get("dim")
686
+ if dim is not None:
687
+ dim = list(dim)
688
+ if _is_na(dim[0]):
689
+ dim[0] = nrow
690
+ if _is_na(dim[1]):
691
+ dim[1] = ncol
692
+ params["dim"] = dim
693
+
694
+ # Decorate per-layer grobs into one panel grob per PANEL.
695
+ panel_grobs = _decorate_panels(panels, layout, ranges, coord, theme)
696
+
697
+ panel_table = self.setup_panel_table(
698
+ panel_grobs, layout, theme, coord, ranges, params
699
+ )
700
+
701
+ axes = render_axes(ranges, ranges, coord, theme, transpose=True)
702
+ axes = self.setup_axes(axes, layout, params, theme)
703
+ panel_table = self.attach_axes(panel_table, axes, axes["measurements"])
704
+
705
+ strip.setup(layout, params, theme, type="wrap")
706
+ panel_table = strip.incorporate_wrap(
707
+ panel_table,
708
+ params.get("strip.position", params.get("strip_position", "top")),
709
+ clip=coord.clip,
710
+ sizes=axes["measurements"],
711
+ )
712
+
713
+ return self.finish_panels(
714
+ panels=panel_table, layout=layout, params=params, theme=theme
715
+ )
716
+
717
+
718
+ # ---------------------------------------------------------------------------
719
+ # Module-private helpers (empties re-placement)
720
+ # ---------------------------------------------------------------------------
721
+ def _is_na(x: Any) -> bool:
722
+ """Return ``True`` when *x* is ``None`` or NaN (R ``is.na``)."""
723
+ if x is None:
724
+ return True
725
+ try:
726
+ return bool(np.isnan(x))
727
+ except (TypeError, ValueError):
728
+ return False
729
+
730
+
731
+ def _rep_unit(u: Unit, length_out: int) -> Unit:
732
+ """Recycle a (scalar) unit to ``length_out`` (R ``rep(u, length.out=n)``)."""
733
+ from grid_py import unit_rep
734
+
735
+ return unit_rep(u, length_out=length_out)
736
+
737
+
738
+ def _diff_down(empties: np.ndarray, target: int, append_last: bool) -> np.ndarray:
739
+ """Port of ``apply(empties, 2, function(x) c(diff(x)==target, FALSE/...))``.
740
+
741
+ Computes, per column, ``diff`` of the boolean mask (as ints) compared to
742
+ *target*, then pads. When ``append_last`` is ``False`` the result is
743
+ ``c(diff==target, FALSE)`` (length nrow, last row FALSE); when ``True`` it is
744
+ ``c(FALSE, diff==target)`` (first row FALSE).
745
+
746
+ Parameters
747
+ ----------
748
+ empties : numpy.ndarray
749
+ ``nrow x ncol`` boolean mask.
750
+ target : int
751
+ ``1`` (non-empty -> empty going down) or ``-1`` (empty -> non-empty).
752
+ append_last : bool
753
+ ``False`` -> pad FALSE at the bottom; ``True`` -> pad FALSE at the top.
754
+
755
+ Returns
756
+ -------
757
+ numpy.ndarray
758
+ ``nrow x ncol`` boolean mask of panels bordering a vertical hole.
759
+ """
760
+ nrow, ncol = empties.shape
761
+ ints = empties.astype(int)
762
+ out = np.zeros((nrow, ncol), dtype=bool)
763
+ if nrow < 2:
764
+ return out
765
+ d = (ints[1:, :] - ints[:-1, :]) == target # (nrow-1, ncol)
766
+ if append_last:
767
+ out[1:, :] = d
768
+ else:
769
+ out[:-1, :] = d
770
+ return out
771
+
772
+
773
+ def _diff_right(empties: np.ndarray, target: int, append_last: bool) -> np.ndarray:
774
+ """Port of ``t(apply(empties, 1, function(x) c(diff(x)==target, ...)))``.
775
+
776
+ Row-wise counterpart of :func:`_diff_down`: detects panels bordering a
777
+ horizontal hole. ``append_last=False`` -> ``c(diff==target, FALSE)`` per row
778
+ (last col FALSE); ``append_last=True`` -> ``c(FALSE, diff==target)`` (first
779
+ col FALSE).
780
+
781
+ Parameters
782
+ ----------
783
+ empties : numpy.ndarray
784
+ target : int
785
+ ``1`` (right neighbour empty) or ``-1`` (left neighbour empty).
786
+ append_last : bool
787
+
788
+ Returns
789
+ -------
790
+ numpy.ndarray
791
+ """
792
+ nrow, ncol = empties.shape
793
+ ints = empties.astype(int)
794
+ out = np.zeros((nrow, ncol), dtype=bool)
795
+ if ncol < 2:
796
+ return out
797
+ d = (ints[:, 1:] - ints[:, :-1]) == target # (nrow, ncol-1)
798
+ if append_last:
799
+ out[:, 1:] = d
800
+ else:
801
+ out[:, :-1] = d
802
+ return out
803
+
804
+
805
+ def _gather_replace(
806
+ side_axes: Sequence[Any],
807
+ scale: Any,
808
+ empties_pos: np.ndarray,
809
+ rc_to_panel: Dict[tuple, int],
810
+ scale_ids: Sequence[int],
811
+ rows: Sequence[int],
812
+ cols: Sequence[int],
813
+ side_list: Sequence[Any],
814
+ ) -> List[Any]:
815
+ """Collect the would-be replacement axis grobs for the warning check.
816
+
817
+ Mirrors R's ``replace <- axes$x$bottom[panels]`` where ``panels`` are the
818
+ panel indices at the hole-bordering positions. Returns the rendered axis
819
+ grobs (by ``SCALE_*``) for each flagged ``(row, col)`` so the caller can test
820
+ whether any is non-zero (the strip-placement="outside" suppression warning).
821
+
822
+ Parameters
823
+ ----------
824
+ side_axes, side_list : sequence
825
+ The rendered per-scale axis list (e.g. ``axes$x$bottom``).
826
+ scale : Any
827
+ Unused (kept for signature clarity).
828
+ empties_pos : numpy.ndarray
829
+ Boolean mask of flagged positions.
830
+ rc_to_panel : dict
831
+ ``(row, col) -> panel-layout-index`` (0-based).
832
+ scale_ids : sequence of int
833
+ Per-panel ``SCALE_X`` / ``SCALE_Y`` (1-based).
834
+ rows, cols : sequence of int
835
+ Per-panel ``ROW`` / ``COL`` (1-based).
836
+
837
+ Returns
838
+ -------
839
+ list of grob
840
+ """
841
+ out: List[Any] = []
842
+ nrow, ncol = empties_pos.shape
843
+ for r in range(nrow):
844
+ for c in range(ncol):
845
+ if not empties_pos[r, c]:
846
+ continue
847
+ panel_idx = rc_to_panel.get((r + 1, c + 1))
848
+ if panel_idx is None:
849
+ out.append(null_grob())
850
+ continue
851
+ sid = scale_ids[panel_idx]
852
+ out.append(side_list[sid - 1])
853
+ return out
854
+
855
+
856
+ def _place_back(
857
+ matrix: List[List[Any]],
858
+ empties_pos: np.ndarray,
859
+ side_list: Sequence[Any],
860
+ scale_ids: Sequence[int],
861
+ rc_to_panel: Dict[tuple, int],
862
+ rows: Sequence[int],
863
+ cols: Sequence[int],
864
+ ) -> None:
865
+ """Re-insert marginal axes at hole-bordering panels (in place).
866
+
867
+ Mirrors R's ``bottom[pos] <- axes$x$bottom[panels]`` block: at each flagged
868
+ position ``(r, c)`` look up the panel that lives there, and write its
869
+ ``SCALE``-indexed rendered axis back into the matrix so the dangling panel
870
+ keeps its marginal axis.
871
+
872
+ Parameters
873
+ ----------
874
+ matrix : list of list
875
+ The side grob matrix being mutated.
876
+ empties_pos : numpy.ndarray
877
+ Boolean mask of positions to re-place.
878
+ side_list : sequence
879
+ The rendered per-scale axis list.
880
+ scale_ids : sequence of int
881
+ Per-panel ``SCALE_*`` (1-based).
882
+ rc_to_panel : dict
883
+ ``(row, col) -> panel-layout-index`` (0-based).
884
+ rows, cols : sequence of int
885
+ Per-panel ``ROW`` / ``COL`` (unused; kept for parity).
886
+ """
887
+ nrow, ncol = empties_pos.shape
888
+ for r in range(nrow):
889
+ for c in range(ncol):
890
+ if not empties_pos[r, c]:
891
+ continue
892
+ panel_idx = rc_to_panel.get((r + 1, c + 1))
893
+ if panel_idx is None:
894
+ continue
895
+ sid = scale_ids[panel_idx]
896
+ matrix[r][c] = side_list[sid - 1]