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_grid2.py ADDED
@@ -0,0 +1,1018 @@
1
+ """Extended grid facets (port of ggh4x ``R/facet_grid2.R``).
2
+
3
+ ``facet_grid2`` behaves like :func:`ggplot2_py.facet_grid` but adds three things:
4
+
5
+ * axes may be drawn (and optionally label-purged) at inner panels (``axes`` /
6
+ ``remove_labels``),
7
+ * position scales may be *independent* within a row or column (``independent``),
8
+ * empty panels may be rendered as blanks (``render_empty``).
9
+
10
+ The :class:`FacetGrid2` ggproto subclasses :class:`ggplot2_py.facet.FacetGrid` and
11
+ *fully replaces* its ``draw_panels`` with a decomposed, strip-pluggable pipeline
12
+ (the explicit reason ggh4x exists -- to give ``facet_nested`` clean override
13
+ seams). Every step (``setup_axes``, ``setup_aspect_ratio``, ``setup_panel_table``,
14
+ ``attach_axes``, ``finish_panels``) is an overridable method.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any, Dict, List, Optional, Sequence
20
+
21
+ import numpy as np
22
+ import pandas as pd
23
+
24
+ from ggplot2_py import calc_element, ggproto
25
+ from ggplot2_py.facet import FacetGrid, _combine_vars, _resolve_facet_vars, facet_grid
26
+ from grid_py import GList, GTree, Unit, edit_viewport, null_grob
27
+ from gtable_py import (
28
+ Gtable,
29
+ gtable_add_col_space,
30
+ gtable_add_grob,
31
+ gtable_add_row_space,
32
+ gtable_filter,
33
+ gtable_height,
34
+ gtable_trim,
35
+ gtable_width,
36
+ )
37
+
38
+ from ggh4x._borrowed_ggplot2 import id, is_zero, snake_class, ulevels
39
+ from ggh4x._cli import cli_abort
40
+ from ggh4x._facet_helpers import (
41
+ AspectRatio,
42
+ _match_facet_arg,
43
+ _validate_independent,
44
+ reshape_add_margins,
45
+ )
46
+ from ggh4x._facet_utils import (
47
+ df_grid,
48
+ render_axes,
49
+ weave_tables_col,
50
+ weave_tables_row,
51
+ )
52
+ from ggh4x._rlang import arg_match0
53
+ from ggh4x.strip_vanilla import resolve_strip
54
+
55
+ __all__ = [
56
+ "facet_grid2",
57
+ "FacetGrid2",
58
+ "new_grid_facets",
59
+ "purge_guide_labels",
60
+ "_measure_axes",
61
+ ]
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Shared axis helpers (R: facet_wrap2.R purge_guide_labels / .measure_axes)
66
+ # ---------------------------------------------------------------------------
67
+ def purge_guide_labels(guide: Any) -> Any:
68
+ """Strip the label grobs from an axis guide, keeping line + ticks.
69
+
70
+ Faithful port of ggh4x's ``purge_guide_labels`` (``R/facet_wrap2.R:396-416``)
71
+ adapted to the ``ggplot2_py`` axis grob layout. R reaches into
72
+ ``guide$children$axis$grobs`` and drops every grob that is a ``titleGrob`` /
73
+ ``richtext_grob`` / ``zeroGrob`` (i.e. the labels), trims the inner axis
74
+ gtable, then resets the guide's reported width/height.
75
+
76
+ In ``ggplot2_py`` :func:`ggplot2_py._guide_axis.draw_axis` returns an
77
+ ``_AbsoluteAxisGrob`` whose children are ``[axis_line, inner_gtable]`` and the
78
+ inner gtable carries the ``"axis.labels"`` cells. This port locates that
79
+ inner gtable, deletes its ``"axis.labels"`` grobs via :func:`gtable_filter`,
80
+ trims it, and refreshes the wrapper's measured width / height.
81
+
82
+ Parameters
83
+ ----------
84
+ guide : grob
85
+ An axis grob (``_AbsoluteAxisGrob``) or a zero grob.
86
+
87
+ Returns
88
+ -------
89
+ grob
90
+ The label-purged axis grob (zero grobs pass through unchanged).
91
+ """
92
+ if is_zero(guide):
93
+ return guide
94
+
95
+ children = list(guide.get_children()) if hasattr(guide, "get_children") else []
96
+ inner_idx = None
97
+ inner_gt = None
98
+ for i, child in enumerate(children):
99
+ if isinstance(child, Gtable):
100
+ inner_idx = i
101
+ inner_gt = child
102
+ break
103
+ if inner_gt is None:
104
+ return guide
105
+
106
+ # Drop the label cells, keep everything else (line, ticks).
107
+ purged = gtable_filter(inner_gt, "axis.labels", trim=False, invert=True)
108
+ purged = gtable_trim(purged)
109
+
110
+ # Replace the inner gtable child in place.
111
+ order = list(guide._children_order) if hasattr(guide, "_children_order") else None
112
+ if order is not None and inner_idx < len(order):
113
+ key = order[inner_idx]
114
+ guide._children[key] = purged
115
+ else:
116
+ # Fallback: rebuild children list.
117
+ new_children = list(children)
118
+ new_children[inner_idx] = purged
119
+ guide.set_children(GList(*new_children))
120
+
121
+ # Reset reported width/height (R: guide$width/height <- sum(axis$...)).
122
+ new_w = gtable_width(purged)
123
+ new_h = gtable_height(purged)
124
+ if hasattr(guide, "_abs_width"):
125
+ guide._abs_width = new_w
126
+ guide._abs_height = new_h
127
+ vp = getattr(guide, "vp", None)
128
+ if vp is not None:
129
+ edits = {}
130
+ if getattr(vp, "width", None) is not None:
131
+ edits["width"] = new_w
132
+ if getattr(vp, "height", None) is not None:
133
+ edits["height"] = new_h
134
+ if edits:
135
+ # grid_py Viewport is immutable (read-only width/height); rebuild it
136
+ # via edit_viewport rather than assigning in place.
137
+ guide.vp = edit_viewport(vp, **edits)
138
+ return guide
139
+
140
+
141
+ def _measure_axes(axes: Dict[str, List[List[Any]]]) -> Dict[str, Unit]:
142
+ """Measure per-row / per-column axis bands in centimetres.
143
+
144
+ Faithful port of ggh4x's ``.measure_axes`` (``R/facet_wrap2.R:435-441``).
145
+ For the ``top`` / ``bottom`` grob matrices the maximum *height* (cm) is taken
146
+ over each matrix **row**; for ``left`` / ``right`` the maximum *width* (cm)
147
+ over each matrix **column**.
148
+
149
+ Parameters
150
+ ----------
151
+ axes : dict
152
+ ``{"top", "bottom", "left", "right"}`` -- each a row-major grob matrix
153
+ (list of lists), one row per panel row and one column per panel column.
154
+
155
+ Returns
156
+ -------
157
+ dict
158
+ ``{"top", "bottom", "left", "right"}`` -- each a ``"cm"`` :class:`Unit`
159
+ of per-band sizes (length = nrow for top/bottom, ncol for left/right).
160
+ """
161
+ from ggplot2_py.facet import _axis_height_cm, _axis_width_cm
162
+
163
+ def _h(g: Any) -> float:
164
+ return 0.0 if is_zero(g) else _axis_height_cm(g)
165
+
166
+ def _w(g: Any) -> float:
167
+ return 0.0 if is_zero(g) else _axis_width_cm(g)
168
+
169
+ top_m = axes["top"]
170
+ bottom_m = axes["bottom"]
171
+ left_m = axes["left"]
172
+ right_m = axes["right"]
173
+ nrow = len(top_m)
174
+ ncol = len(top_m[0]) if nrow else 0
175
+
176
+ top = [max((_h(top_m[r][c]) for c in range(ncol)), default=0.0) for r in range(nrow)]
177
+ bottom = [
178
+ max((_h(bottom_m[r][c]) for c in range(ncol)), default=0.0) for r in range(nrow)
179
+ ]
180
+ left = [max((_w(left_m[r][c]) for r in range(nrow)), default=0.0) for c in range(ncol)]
181
+ right = [
182
+ max((_w(right_m[r][c]) for r in range(nrow)), default=0.0) for c in range(ncol)
183
+ ]
184
+ return {
185
+ "top": Unit(top, "cm"),
186
+ "bottom": Unit(bottom, "cm"),
187
+ "left": Unit(left, "cm"),
188
+ "right": Unit(right, "cm"),
189
+ }
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Constructor
194
+ # ---------------------------------------------------------------------------
195
+ def facet_grid2(
196
+ rows: Any = None,
197
+ cols: Any = None,
198
+ scales: Any = "fixed",
199
+ space: Any = "fixed",
200
+ axes: Any = "margins",
201
+ remove_labels: Any = "none",
202
+ independent: Any = "none",
203
+ shrink: bool = True,
204
+ labeller: Any = "label_value",
205
+ as_table: bool = True,
206
+ switch: Optional[str] = None,
207
+ drop: bool = True,
208
+ margins: Any = False,
209
+ render_empty: bool = True,
210
+ strip: Any = "vanilla",
211
+ ) -> "FacetGrid2":
212
+ """Extended grid facets.
213
+
214
+ Port of ggh4x's ``facet_grid2()`` (``R/facet_grid2.R:100-124``). Like
215
+ :func:`ggplot2_py.facet_grid` but can draw partial / full axis guides at inner
216
+ panels and supports independent position scales.
217
+
218
+ Parameters
219
+ ----------
220
+ rows, cols : formula / list / dict / None
221
+ Faceting variables for rows and columns (same spec as ``facet_grid``).
222
+ scales : {"fixed", "free_x", "free_y", "free"} or bool, default "fixed"
223
+ Whether scales are shared or free across facets.
224
+ space : {"fixed", "free_x", "free_y", "free"} or bool, default "fixed"
225
+ Whether panel sizes are proportional to the scales.
226
+ axes : {"margins", "x", "y", "all"} or bool, default "margins"
227
+ Where inner axes are drawn.
228
+ remove_labels : {"none", "x", "y", "all"} or bool, default "none"
229
+ Whether inner-axis text is removed.
230
+ independent : {"none", "x", "y", "all"} or bool, default "none"
231
+ Whether scales vary within a row / column.
232
+ shrink : bool, default True
233
+ Shrink scales to fit stat output.
234
+ labeller : callable or str, default "label_value"
235
+ Strip labeller.
236
+ as_table : bool, default True
237
+ When ``False``, reverse the row factor level order.
238
+ switch : {"x", "y", "both", None}, default None
239
+ Which strips switch sides.
240
+ drop : bool, default True
241
+ Drop unused factor levels.
242
+ margins : bool or list of str, default False
243
+ Add marginal panels.
244
+ render_empty : bool, default True
245
+ Draw data-less panels (``True``) or blank them (``False``).
246
+ strip : Strip or callable or str, default "vanilla"
247
+ Strip specification.
248
+
249
+ Returns
250
+ -------
251
+ FacetGrid2
252
+ A ggproto facet object that can be added to a plot.
253
+ """
254
+ return new_grid_facets(
255
+ rows,
256
+ cols,
257
+ scales,
258
+ space,
259
+ axes,
260
+ remove_labels,
261
+ independent,
262
+ shrink,
263
+ labeller,
264
+ as_table,
265
+ switch,
266
+ drop,
267
+ margins,
268
+ render_empty,
269
+ strip,
270
+ super_=FacetGrid2,
271
+ )
272
+
273
+
274
+ def new_grid_facets(
275
+ rows: Any,
276
+ cols: Any,
277
+ scales: Any,
278
+ space: Any,
279
+ axes: Any,
280
+ rmlab: Any,
281
+ indy: Any,
282
+ shrink: bool,
283
+ labeller: Any,
284
+ as_table: bool,
285
+ switch: Optional[str],
286
+ drop: bool,
287
+ margins: Any,
288
+ render_empty: bool,
289
+ strip: Any,
290
+ params: Optional[Dict[str, Any]] = None,
291
+ super_: Any = None,
292
+ ) -> "FacetGrid2":
293
+ """Build a :class:`FacetGrid2` instance from raw arguments.
294
+
295
+ Port of ggh4x's ``new_grid_facets()`` (``R/facet_grid2.R:128-171``).
296
+ Normalises the option arguments, validates the ``independent`` interactions,
297
+ resolves the formula spec via :func:`ggplot2_py.facet_grid`, resolves the
298
+ strip, assembles the parameter dict and instantiates the ggproto.
299
+
300
+ Parameters
301
+ ----------
302
+ rows, cols : Any
303
+ Faceting variable specs.
304
+ scales, space, axes, rmlab, indy : Any
305
+ Option arguments (normalised via :func:`_match_facet_arg`).
306
+ shrink : bool
307
+ labeller : Any
308
+ as_table : bool
309
+ switch : str or None
310
+ drop : bool
311
+ margins : Any
312
+ render_empty : bool
313
+ strip : Any
314
+ params : dict, optional
315
+ Extra params merged in (used by ``facet_nested``).
316
+ super_ : type, optional
317
+ The ggproto class to instantiate (default :class:`FacetGrid2`).
318
+
319
+ Returns
320
+ -------
321
+ FacetGrid2
322
+ """
323
+ if super_ is None:
324
+ super_ = FacetGrid2
325
+ params = dict(params or {})
326
+
327
+ switch = switch if switch is not None else "none"
328
+ switch = arg_match0(switch, ["none", "both", "x", "y"], arg_name="switch")
329
+ switch_param = None if switch == "none" else switch
330
+
331
+ axes = _match_facet_arg(axes, ["margins", "x", "y", "all"], nm="axes")
332
+ free = _match_facet_arg(scales, ["fixed", "free_x", "free_y", "free"], nm="scales")
333
+ space = _match_facet_arg(space, ["fixed", "free_x", "free_y", "free"], nm="space")
334
+ rmlab = _match_facet_arg(rmlab, ["none", "x", "y", "all"], nm="remove_labels")
335
+ indy = _match_facet_arg(indy, ["none", "x", "y", "all"], nm="independent")
336
+ strip = resolve_strip(strip)
337
+
338
+ axis_params = _validate_independent(indy, free, space, rmlab)
339
+
340
+ # Resolve the formula -> rows / cols var lists via the base facet_grid.
341
+ # Store as name lists (R keeps a named quosure list; the strip / layout read
342
+ # the names) so both the strip subsystem and ``compute_layout`` agree.
343
+ facets = facet_grid(rows=rows, cols=cols).params
344
+ proto_rows = _resolve_facet_vars(facets["rows"])
345
+ proto_cols = _resolve_facet_vars(facets["cols"])
346
+
347
+ params.update(axis_params)
348
+ params.update(
349
+ {
350
+ "rows": proto_rows,
351
+ "cols": proto_cols,
352
+ "margins": margins,
353
+ "labeller": labeller,
354
+ "as_table": as_table,
355
+ "switch": switch_param,
356
+ "drop": drop,
357
+ "axes": axes,
358
+ "render_empty": render_empty is not False,
359
+ }
360
+ )
361
+
362
+ obj = super_()
363
+ obj._set(shrink=shrink, strip=strip, params=params)
364
+ return obj
365
+
366
+
367
+ # ---------------------------------------------------------------------------
368
+ # ggproto
369
+ # ---------------------------------------------------------------------------
370
+ def _as_name_list(spec: Any) -> List[str]:
371
+ """Return the faceting-variable names from a ``rows``/``cols`` spec."""
372
+ if spec is None:
373
+ return []
374
+ if isinstance(spec, dict):
375
+ return list(spec.keys())
376
+ if isinstance(spec, (list, tuple)):
377
+ return [str(s) for s in spec]
378
+ if isinstance(spec, str):
379
+ return [spec]
380
+ return []
381
+
382
+
383
+ class FacetGrid2(FacetGrid):
384
+ """Extended grid facet ggproto (port of R ``FacetGrid2``).
385
+
386
+ Subclasses :class:`ggplot2_py.facet.FacetGrid`. Replaces ``compute_layout``
387
+ (adds a ``_render`` column, ``id()``-stable ordering and independent-axis
388
+ ``SCALE_X``/``SCALE_Y`` assignment) and ``draw_panels`` (a decomposed,
389
+ strip-pluggable pipeline). All drawing sub-steps are overridable methods so
390
+ ``facet_nested`` can hook them.
391
+
392
+ Attributes
393
+ ----------
394
+ shrink : bool
395
+ strip : Strip
396
+ The pluggable strip instance.
397
+ params : dict
398
+ Facet parameters (carries ``independent``, ``free``, ``space_free``,
399
+ ``rmlab``, ``axes``, ``render_empty``, ``rows``, ``cols``, ...).
400
+ """
401
+
402
+ _class_name = "FacetGrid2"
403
+
404
+ shrink: bool = True
405
+ strip: Any = None
406
+
407
+ # -- vars_combine (extension seam) --------------------------------------
408
+ def vars_combine(
409
+ self,
410
+ data: List[pd.DataFrame],
411
+ env: Any,
412
+ vars_: Any,
413
+ drop: bool = True,
414
+ ) -> pd.DataFrame:
415
+ """Combine the faceting variables across datasets.
416
+
417
+ Extension seam mirroring ggh4x's ``FacetGrid2$vars_combine``
418
+ (``R/facet_grid2.R:190-192``) which delegates to ggplot2's
419
+ ``combine_vars``. ``facet_nested`` overrides this to substitute its own
420
+ variable-combination logic, so it must remain a real method called via
421
+ ``self.vars_combine(...)``.
422
+
423
+ Parameters
424
+ ----------
425
+ data : list of DataFrame
426
+ The plot + layer data frames.
427
+ env : Any
428
+ The plot environment (unused -- kept for R signature parity).
429
+ vars_ : dict or list of str
430
+ The variable names (the ``rows`` / ``cols`` spec).
431
+ drop : bool, default True
432
+ Drop unused factor combinations.
433
+
434
+ Returns
435
+ -------
436
+ pandas.DataFrame
437
+ Unique combinations of the requested variables.
438
+ """
439
+ names = _as_name_list(vars_)
440
+ return _combine_vars(data, names, drop=drop)
441
+
442
+ # -- compute_layout -----------------------------------------------------
443
+ def compute_layout(
444
+ self,
445
+ data: List[pd.DataFrame],
446
+ params: Dict[str, Any],
447
+ ) -> pd.DataFrame:
448
+ """Build the panel layout with ``_render`` + independent-scale columns.
449
+
450
+ Port of ggh4x's ``FacetGrid2$compute_layout`` (``R/facet_grid2.R:193-283``).
451
+
452
+ Parameters
453
+ ----------
454
+ data : list of DataFrame
455
+ params : dict
456
+
457
+ Returns
458
+ -------
459
+ pandas.DataFrame
460
+ Layout with ``PANEL``, ``ROW``, ``COL``, ``SCALE_X``, ``SCALE_Y``, a
461
+ ggh4x-specific boolean ``_render`` column, and the faceting-var
462
+ columns.
463
+ """
464
+ rows = params["rows"]
465
+ cols = params["cols"]
466
+ row_names = _as_name_list(rows)
467
+ col_names = _as_name_list(cols)
468
+
469
+ dups = [d for d in row_names if d in col_names]
470
+ if dups:
471
+ cli_abort(
472
+ "Facetting variables can only appear in `rows` or `cols`, not "
473
+ f"both. Duplicated variables: {dups}"
474
+ )
475
+
476
+ drop = params.get("drop", True)
477
+ env = params.get("plot_env")
478
+
479
+ base_rows = self.vars_combine(data, env, rows, drop=drop)
480
+ if not params.get("as_table", True):
481
+ base_rows = base_rows.copy()
482
+ for c in base_rows.columns:
483
+ levels = list(ulevels(base_rows[c]))[::-1]
484
+ base_rows[c] = pd.Categorical(base_rows[c], categories=levels)
485
+
486
+ base_cols = self.vars_combine(data, env, cols, drop=drop)
487
+ base = df_grid(base_rows, base_cols)
488
+
489
+ if base is None or len(base) == 0:
490
+ return pd.DataFrame(
491
+ {
492
+ "PANEL": pd.Categorical([1]),
493
+ "ROW": [1],
494
+ "COL": [1],
495
+ "SCALE_X": [1],
496
+ "SCALE_Y": [1],
497
+ "_render": [True],
498
+ }
499
+ )
500
+
501
+ base = reshape_add_margins(
502
+ base, [row_names, col_names], params.get("margins", False)
503
+ )
504
+ base = base.drop_duplicates().reset_index(drop=True)
505
+
506
+ if not params.get("render_empty", True):
507
+ both = {**(rows or {}), **(cols or {})} if isinstance(rows, dict) else None
508
+ if both is not None:
509
+ universe = self.vars_combine(data, env, both, drop=drop)
510
+ else:
511
+ universe = self.vars_combine(
512
+ data, env, row_names + col_names, drop=drop
513
+ )
514
+ keys = [c for c in base.columns if c in universe.columns]
515
+ if keys:
516
+ uni_rows = set(
517
+ tuple(r) for r in universe[keys].itertuples(index=False, name=None)
518
+ )
519
+ render = [
520
+ tuple(r) in uni_rows
521
+ for r in base[keys].itertuples(index=False, name=None)
522
+ ]
523
+ else:
524
+ render = [True] * len(base)
525
+ else:
526
+ render = [True] * len(base)
527
+
528
+ # PANEL / ROW / COL via id() (R radix ordering, NOT Categorical.codes).
529
+ panel = id(base, drop=True)
530
+ n_panel = int(panel.n)
531
+
532
+ if not row_names:
533
+ row_ids = np.array([1] * len(base), dtype=int)
534
+ else:
535
+ row_ids = np.asarray(id(base[row_names], drop=True), dtype=int)
536
+ if not col_names:
537
+ col_ids = np.array([1] * len(base), dtype=int)
538
+ else:
539
+ col_ids = np.asarray(id(base[col_names], drop=True), dtype=int)
540
+
541
+ panel_int = np.asarray(panel, dtype=int)
542
+ panels = base.copy()
543
+ panels.insert(0, "PANEL", pd.Categorical(panel_int, categories=range(1, n_panel + 1)))
544
+ panels.insert(1, "ROW", row_ids)
545
+ panels.insert(2, "COL", col_ids)
546
+ panels["_render"] = render
547
+
548
+ # order(PANEL)
549
+ order = np.argsort(panel_int, kind="mergesort")
550
+ panels = panels.iloc[order].reset_index(drop=True)
551
+
552
+ free = params["free"]
553
+ independent = params["independent"]
554
+ n = len(panels)
555
+
556
+ if free["x"]:
557
+ if independent["x"]:
558
+ panels["SCALE_X"] = np.arange(1, n + 1)
559
+ else:
560
+ panels["SCALE_X"] = panels["COL"].to_numpy()
561
+ else:
562
+ panels["SCALE_X"] = 1
563
+
564
+ if free["y"]:
565
+ if independent["y"]:
566
+ panels["SCALE_Y"] = np.arange(1, n + 1)
567
+ else:
568
+ panels["SCALE_Y"] = panels["ROW"].to_numpy()
569
+ else:
570
+ panels["SCALE_Y"] = 1
571
+
572
+ return panels
573
+
574
+ # -- setup_aspect_ratio -------------------------------------------------
575
+ def setup_aspect_ratio(
576
+ self,
577
+ coord: Any,
578
+ free: Dict[str, bool],
579
+ theme: Any,
580
+ ranges: Sequence[Any],
581
+ ) -> AspectRatio:
582
+ """Resolve the panel aspect ratio + ``respect`` flag.
583
+
584
+ Port of ggh4x's ``FacetGrid2$setup_aspect_ratio``
585
+ (``R/facet_grid2.R:284-296``). Uses ``theme$aspect.ratio`` if set; else
586
+ ``coord$aspect(ranges[[1]])`` when neither dimension is free; else ``1``
587
+ with ``respect=False``.
588
+
589
+ Parameters
590
+ ----------
591
+ coord : Coord
592
+ free : dict
593
+ ``{"x": bool, "y": bool}``.
594
+ theme : Theme
595
+ ranges : sequence
596
+ Per-panel ``panel_params`` dicts.
597
+
598
+ Returns
599
+ -------
600
+ AspectRatio
601
+ """
602
+ aspect_ratio = calc_element("aspect.ratio", theme) if theme is not None else None
603
+ if aspect_ratio is None and not free["x"] and not free["y"] and ranges:
604
+ aspect_ratio = coord.aspect(ranges[0])
605
+ if aspect_ratio is None:
606
+ return AspectRatio(1.0, False)
607
+ return AspectRatio(float(aspect_ratio), True)
608
+
609
+ # -- setup_panel_table --------------------------------------------------
610
+ def setup_panel_table(
611
+ self,
612
+ panels: List[Any],
613
+ layout: pd.DataFrame,
614
+ space: Dict[str, bool],
615
+ ranges: Sequence[Any],
616
+ aspect: AspectRatio,
617
+ clip: str,
618
+ theme: Any,
619
+ ) -> Gtable:
620
+ """Build the panel gtable (byrow matrix, null-unit / proportional sizes).
621
+
622
+ Port of ggh4x's ``FacetGrid2$setup_panel_table``
623
+ (``R/facet_grid2.R:297-340``). Blanks non-rendered panels, lays panels
624
+ into a ``nrow x ncol`` gtable at ``(ROW, COL)`` with ``z=1``, sizes
625
+ columns / rows as ``"null"`` units (proportional to ``diff(range)`` when
626
+ ``space`` is free, heights scaled by ``abs(aspect)`` otherwise), then adds
627
+ panel spacing.
628
+
629
+ Parameters
630
+ ----------
631
+ panels : list of grob
632
+ One decorated panel grob per PANEL (PANEL-ordered).
633
+ layout : pandas.DataFrame
634
+ The facet layout (carries ``ROW``, ``COL``, ``_render``).
635
+ space : dict
636
+ ``{"x": bool, "y": bool}`` -- ``space_free`` flags.
637
+ ranges : sequence
638
+ Per-panel ``panel_params`` dicts.
639
+ aspect : AspectRatio
640
+ clip : str
641
+ Panel clip setting (``coord.clip``).
642
+ theme : Theme
643
+
644
+ Returns
645
+ -------
646
+ Gtable
647
+ """
648
+ panels = list(panels)
649
+ render = list(layout["_render"]) if "_render" in layout.columns else [True] * len(panels)
650
+ panels = [p if render[i] else null_grob() for i, p in enumerate(panels)]
651
+
652
+ ncol = int(layout["COL"].max())
653
+ nrow = int(layout["ROW"].max())
654
+
655
+ panel_arr = np.asarray(layout["PANEL"]).astype(int)
656
+
657
+ if space["x"]:
658
+ row1 = layout[layout["ROW"] == 1]
659
+ ps = [int(p) for p in row1["PANEL"]]
660
+ widths_vals = [
661
+ _range_diff(ranges[i - 1], "x") for i in ps
662
+ ]
663
+ widths = Unit(widths_vals, "null")
664
+ else:
665
+ widths = Unit([1.0] * ncol, "null")
666
+
667
+ if space["y"]:
668
+ col1 = layout[layout["COL"] == 1]
669
+ ps = [int(p) for p in col1["PANEL"]]
670
+ heights_vals = [
671
+ _range_diff(ranges[i - 1], "y") for i in ps
672
+ ]
673
+ heights = Unit(heights_vals, "null")
674
+ else:
675
+ heights = Unit([1.0 * abs(aspect.value)] * nrow, "null")
676
+
677
+ panel_table = Gtable(widths=widths, heights=heights, respect=aspect.respect)
678
+
679
+ rows_idx = [int(v) for v in layout["ROW"]]
680
+ cols_idx = [int(v) for v in layout["COL"]]
681
+ # R: paste0("panel-", rep(seq_len(nrow), ncol), "-", rep(seq_len(ncol), each = nrow))
682
+ # This is a fixed *positional* name vector applied to the PANEL-ordered
683
+ # ``panels`` list -- the suffixes do NOT track each panel's actual
684
+ # ROW/COL, they enumerate row-fastest, column-slowest.
685
+ name_i = [((k % nrow) + 1) for k in range(nrow * ncol)]
686
+ name_j = [((k // nrow) + 1) for k in range(nrow * ncol)]
687
+ names = [f"panel-{name_i[k]}-{name_j[k]}" for k in range(len(panels))]
688
+ panel_table = gtable_add_grob(
689
+ panel_table,
690
+ panels,
691
+ t=rows_idx,
692
+ l=cols_idx,
693
+ z=1,
694
+ clip=clip,
695
+ name=names,
696
+ )
697
+ panel_table = gtable_add_col_space(
698
+ panel_table, calc_element("panel.spacing.x", theme)
699
+ )
700
+ panel_table = gtable_add_row_space(
701
+ panel_table, calc_element("panel.spacing.y", theme)
702
+ )
703
+ return panel_table
704
+
705
+ # -- attach_axes --------------------------------------------------------
706
+ def attach_axes(self, panel_table: Gtable, axes: Dict[str, Any]) -> Gtable:
707
+ """Weave the four axis bands into the panel gtable.
708
+
709
+ Port of ggh4x's ``FacetGrid2$attach_axes`` (``R/facet_grid2.R:341-356``).
710
+ Measures the axes (:func:`_measure_axes`) then weaves the top (shift -1),
711
+ bottom (shift 0), left (shift -1) and right (shift 0) bands at ``z=3``.
712
+
713
+ Parameters
714
+ ----------
715
+ panel_table : Gtable
716
+ axes : dict
717
+ ``{"top", "bottom", "left", "right"}`` grob matrices from
718
+ :meth:`setup_axes`.
719
+
720
+ Returns
721
+ -------
722
+ Gtable
723
+ """
724
+ sizes = _measure_axes(axes)
725
+ panel_table = weave_tables_row(
726
+ panel_table, axes["top"], -1, sizes["top"], "axis-t", 3
727
+ )
728
+ panel_table = weave_tables_row(
729
+ panel_table, axes["bottom"], 0, sizes["bottom"], "axis-b", 3
730
+ )
731
+ panel_table = weave_tables_col(
732
+ panel_table, axes["left"], -1, sizes["left"], "axis-l", 3
733
+ )
734
+ panel_table = weave_tables_col(
735
+ panel_table, axes["right"], 0, sizes["right"], "axis-r", 3
736
+ )
737
+ return panel_table
738
+
739
+ # -- setup_axes ---------------------------------------------------------
740
+ def setup_axes(
741
+ self,
742
+ axes: Dict[str, Any],
743
+ empty: List[List[Any]],
744
+ position: Sequence[int],
745
+ layout: pd.DataFrame,
746
+ params: Dict[str, Any],
747
+ ) -> Dict[str, List[List[Any]]]:
748
+ """Fill the 4 axis grob matrices by scale-id and blank interior axes.
749
+
750
+ Port of ggh4x's ``FacetGrid2$setup_axes`` (``R/facet_grid2.R:358-394``).
751
+ Fills the top/bottom/left/right ``nrow x ncol`` matrices by the per-cell
752
+ ``position`` (panel) index, blanks redundant interior axes unless they
753
+ must repeat (``independent`` | ``axes``), and purges labels from interior
754
+ axes when ``axes & rmlab & !independent``.
755
+
756
+ Parameters
757
+ ----------
758
+ axes : dict
759
+ The transposed batch axes ``{"x": {"top", "bottom"},
760
+ "y": {"left", "right"}}`` from :func:`render_axes`.
761
+ empty : list of list
762
+ A ``nrow x ncol`` zero-grob matrix template.
763
+ position : sequence of int
764
+ The per-cell panel index (row-major / byrow), 1-based.
765
+ layout : pandas.DataFrame
766
+ params : dict
767
+
768
+ Returns
769
+ -------
770
+ dict
771
+ ``{"top", "bottom", "left", "right"}`` grob matrices (list of lists).
772
+ """
773
+ nrow = len(empty)
774
+ ncol = len(empty[0]) if nrow else 0
775
+
776
+ def _new() -> List[List[Any]]:
777
+ return [[null_grob() for _ in range(ncol)] for _ in range(nrow)]
778
+
779
+ top = _new()
780
+ bottom = _new()
781
+ left = _new()
782
+ right = _new()
783
+
784
+ x_top = axes["x"]["top"]
785
+ x_bottom = axes["x"]["bottom"]
786
+ y_left = axes["y"]["left"]
787
+ y_right = axes["y"]["right"]
788
+
789
+ # position is row-major: position[r*ncol + c] = panel index (already the
790
+ # PANEL-ordered axis index because axes were rendered over ranges[panel_pos]).
791
+ k = 0
792
+ for r in range(nrow):
793
+ for c in range(ncol):
794
+ # axes$x$top[position] where the rendered list is itself indexed
795
+ # by the same row-major order, so cell (r,c) takes element k.
796
+ top[r][c] = x_top[k]
797
+ bottom[r][c] = x_bottom[k]
798
+ left[r][c] = y_left[k]
799
+ right[r][c] = y_right[k]
800
+ k += 1
801
+
802
+ independent = params["independent"]
803
+ axes_p = params["axes"]
804
+ rmlab = params["rmlab"]
805
+ repeat_x = independent["x"] or axes_p["x"]
806
+ repeat_y = independent["y"] or axes_p["y"]
807
+
808
+ if not repeat_x:
809
+ # top[-1, ]: blank all rows except first.
810
+ for r in range(1, nrow):
811
+ for c in range(ncol):
812
+ top[r][c] = null_grob()
813
+ # bottom[-nrow, ]: blank all rows except last.
814
+ for r in range(nrow - 1):
815
+ for c in range(ncol):
816
+ bottom[r][c] = null_grob()
817
+ if not repeat_y:
818
+ # left[, -1]: blank all cols except first.
819
+ for r in range(nrow):
820
+ for c in range(1, ncol):
821
+ left[r][c] = null_grob()
822
+ # right[, -ncol]: blank all cols except last.
823
+ for r in range(nrow):
824
+ for c in range(ncol - 1):
825
+ right[r][c] = null_grob()
826
+
827
+ if axes_p["x"] and rmlab["x"] and not independent["x"]:
828
+ for r in range(1, nrow):
829
+ for c in range(ncol):
830
+ top[r][c] = purge_guide_labels(top[r][c])
831
+ for r in range(nrow - 1):
832
+ for c in range(ncol):
833
+ bottom[r][c] = purge_guide_labels(bottom[r][c])
834
+ if axes_p["y"] and rmlab["y"] and not independent["y"]:
835
+ for r in range(nrow):
836
+ for c in range(1, ncol):
837
+ left[r][c] = purge_guide_labels(left[r][c])
838
+ for r in range(nrow):
839
+ for c in range(ncol - 1):
840
+ right[r][c] = purge_guide_labels(right[r][c])
841
+
842
+ return {"top": top, "bottom": bottom, "left": left, "right": right}
843
+
844
+ # -- finish_panels (identity seam) --------------------------------------
845
+ def finish_panels(
846
+ self,
847
+ panels: Any,
848
+ layout: pd.DataFrame,
849
+ params: Dict[str, Any],
850
+ theme: Any,
851
+ ) -> Any:
852
+ """Identity post-processing hook (extension seam).
853
+
854
+ Port of ggh4x's ``FacetGrid2$finish_panels`` (``R/facet_grid2.R:395-397``).
855
+ Returns *panels* unchanged; subclasses (``facet_nested``) override it.
856
+ """
857
+ return panels
858
+
859
+ # -- draw_panels (full override) ----------------------------------------
860
+ def draw_panels(
861
+ self,
862
+ panels: list,
863
+ layout: pd.DataFrame,
864
+ x_scales: list,
865
+ y_scales: list,
866
+ ranges: list,
867
+ coord: Any,
868
+ data: Any,
869
+ theme: Any,
870
+ params: Dict[str, Any],
871
+ ) -> Gtable:
872
+ """Assemble the grid panel gtable (full replacement of the base pipeline).
873
+
874
+ Port of ggh4x's ``FacetGrid2$draw_panels`` (``R/facet_grid2.R:398-433``).
875
+ Builds the byrow ``panel_pos`` vector, batch-renders the axes
876
+ (``transpose=True``), then runs ``setup_axes`` -> ``setup_aspect_ratio``
877
+ -> ``setup_panel_table`` -> ``attach_axes`` -> strip ``setup`` /
878
+ ``incorporate_grid`` -> ``finish_panels``. Does **not** chain to the base
879
+ ``draw_panels``.
880
+
881
+ Parameters
882
+ ----------
883
+ panels : list
884
+ Per-layer lists of per-panel geom grobs (``ggplot2_py`` shape), or an
885
+ already-flat list of decorated panel grobs.
886
+ layout : pandas.DataFrame
887
+ x_scales, y_scales : list
888
+ ranges : list
889
+ Per-panel ``panel_params`` dicts.
890
+ coord : Coord
891
+ data : Any
892
+ theme : Theme
893
+ params : dict
894
+
895
+ Returns
896
+ -------
897
+ Gtable
898
+ """
899
+ if (params["free"]["x"] or params["free"]["y"]) and not coord.is_free():
900
+ cli_abort(f"`{snake_class(coord)}` doesn't support free scales.")
901
+
902
+ strip = self.strip
903
+ ncol = int(layout["COL"].max())
904
+ nrow = int(layout["ROW"].max())
905
+ empty_table = [[null_grob() for _ in range(ncol)] for _ in range(nrow)]
906
+
907
+ # Decorate per-layer grobs into one panel grob per PANEL.
908
+ panel_grobs = _decorate_panels(panels, layout, ranges, coord, theme)
909
+
910
+ # panel_pos: byrow reshape of as.integer(PANEL).
911
+ panel_int = [int(p) for p in layout["PANEL"]]
912
+ # Map (ROW,COL) -> PANEL for byrow ordering.
913
+ rc_to_panel: Dict[tuple, int] = {}
914
+ for _, row in layout.iterrows():
915
+ rc_to_panel[(int(row["ROW"]), int(row["COL"]))] = int(row["PANEL"])
916
+ panel_pos: List[int] = []
917
+ for r in range(1, nrow + 1):
918
+ for c in range(1, ncol + 1):
919
+ panel_pos.append(rc_to_panel.get((r, c), 1))
920
+
921
+ ranges_pos = [ranges[p - 1] for p in panel_pos]
922
+ axes = render_axes(ranges_pos, ranges_pos, coord, theme, transpose=True)
923
+ axes = self.setup_axes(axes, empty_table, panel_pos, layout, params)
924
+
925
+ aspect_ratio = self.setup_aspect_ratio(coord, params["free"], theme, ranges)
926
+
927
+ panel_table = self.setup_panel_table(
928
+ panel_grobs, layout, params["space_free"], ranges, aspect_ratio,
929
+ coord.clip, theme,
930
+ )
931
+ panel_table = self.attach_axes(panel_table, axes)
932
+
933
+ strip.setup(layout, params, theme, type="grid")
934
+ panel_table = strip.incorporate_grid(panel_table, params["switch"])
935
+
936
+ return self.finish_panels(
937
+ panels=panel_table, layout=layout, params=params, theme=theme
938
+ )
939
+
940
+
941
+ # ---------------------------------------------------------------------------
942
+ # Module-private helpers
943
+ # ---------------------------------------------------------------------------
944
+ def _range_diff(panel_params: Any, axis: str) -> float:
945
+ """Return ``diff(range)`` for the x or y axis of a ``panel_params`` dict.
946
+
947
+ ``ggplot2_py`` exposes both ``"x_range"`` / ``"y_range"`` and the R-style
948
+ ``"x.range"`` / ``"y.range"`` keys; prefer the R-style key to match the
949
+ gold-standard, falling back to the underscore variant.
950
+ """
951
+ if panel_params is None:
952
+ return 1.0
953
+ key_dot = f"{axis}.range"
954
+ key_us = f"{axis}_range"
955
+ rng = panel_params.get(key_dot)
956
+ if rng is None:
957
+ rng = panel_params.get(key_us)
958
+ if rng is None:
959
+ return 1.0
960
+ return float(rng[1] - rng[0])
961
+
962
+
963
+ def _decorate_panels(
964
+ panels: list,
965
+ layout: pd.DataFrame,
966
+ ranges: list,
967
+ coord: Any,
968
+ theme: Any,
969
+ ) -> List[Any]:
970
+ """Compose per-layer geom grobs into one decorated panel grob per PANEL.
971
+
972
+ ``ggplot2_py`` passes ``panels`` to ``draw_panels`` as a list-of-layers (each
973
+ a list of per-panel grobs); R passes a flat list of already-decorated panel
974
+ grobs (one per PANEL). This bridges the two by running
975
+ ``coord.draw_panel(layer_grobs_for_panel, pp, theme)`` per panel, matching the
976
+ base ``Facet.draw_panels`` decoration (facet.py:766-782). When *panels* is
977
+ already flat (one grob per panel) it is returned as-is.
978
+
979
+ Parameters
980
+ ----------
981
+ panels : list
982
+ layout : pandas.DataFrame
983
+ ranges : list
984
+ coord : Coord
985
+ theme : Theme
986
+
987
+ Returns
988
+ -------
989
+ list of grob
990
+ One decorated panel grob per PANEL, PANEL-ordered.
991
+ """
992
+ n_panel = len(layout)
993
+ # Detect the already-flat case: a list of length n_panel of non-list grobs.
994
+ if (
995
+ len(panels) == n_panel
996
+ and n_panel > 0
997
+ and not isinstance(panels[0], list)
998
+ ):
999
+ return list(panels)
1000
+
1001
+ out: List[Any] = []
1002
+ panel_order = sorted(int(p) for p in layout["PANEL"])
1003
+ for panel_id in panel_order:
1004
+ panel_idx = panel_id - 1
1005
+ pp = ranges[panel_idx] if panel_idx < len(ranges) else {}
1006
+ layer_grobs: List[Any] = []
1007
+ for layer in panels:
1008
+ if isinstance(layer, list):
1009
+ if panel_idx < len(layer):
1010
+ layer_grobs.append(layer[panel_idx])
1011
+ elif layer is not None:
1012
+ layer_grobs.append(layer)
1013
+ if hasattr(coord, "draw_panel"):
1014
+ decorated = coord.draw_panel(layer_grobs, pp, theme)
1015
+ else:
1016
+ decorated = GTree(children=GList(*layer_grobs), name=f"panel-{panel_id}")
1017
+ out.append(decorated)
1018
+ return out