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_nested.py ADDED
@@ -0,0 +1,776 @@
1
+ """Nested-strip grid facets (port of ggh4x ``R/facet_nested.R``).
2
+
3
+ ``facet_nested()`` behaves like :func:`ggh4x.facet_grid2` but merges adjacent
4
+ strips that share an (outer) faceting-variable value into a single spanning
5
+ strip, and -- unlike ``facet_grid()`` -- only auto-expands a missing faceting
6
+ variable when there is **no** variable in that direction at all (so partially
7
+ faceted layers are allowed, the defining feature of nesting). Hierarchy lines
8
+ (``nest_line``) are drawn between strip layers to indicate the grouping.
9
+
10
+ The :class:`FacetNested` ggproto subclasses :class:`ggh4x.facet_grid2.FacetGrid2`
11
+ and overrides three seams:
12
+
13
+ * :meth:`FacetNested.map_data` -- assign data rows to panels, treating a
14
+ faceting variable as "missing" (and force-expanding) only when *no* variable
15
+ in its direction is present in the layer (R: ``facet_nested.R:130-187``).
16
+ * :meth:`FacetNested.vars_combine` -- build the cross-product base of facet
17
+ values, permitting layers missing some vars by blank-filling (``""``) the
18
+ absent columns rather than erroring (R: ``facet_nested.R:188-234``).
19
+ * :meth:`FacetNested.finish_panels` -- draw the nest indicator lines via
20
+ :func:`add_nest_indicator` (R: ``facet_nested.R:236-238``).
21
+
22
+ The default strip is :func:`ggh4x.strip_nested.strip_nested` (the label-merging
23
+ nested strip), matching R's ``strip = "nested"``.
24
+
25
+ R source: ``ggh4x/R/facet_nested.R``.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import warnings
31
+ from typing import Any, Dict, List, Optional, Sequence
32
+
33
+ import numpy as np
34
+ import pandas as pd
35
+
36
+ from ggplot2_py.theme_elements import (
37
+ ElementBlank,
38
+ calc_element,
39
+ combine_elements,
40
+ element_blank,
41
+ element_grob,
42
+ element_line,
43
+ is_theme_element,
44
+ )
45
+ from grid_py import Unit, unit_c
46
+ from gtable_py import gtable_add_grob
47
+
48
+ from ggh4x._borrowed_ggplot2 import empty, id, unique_combs
49
+ from ggh4x._cli import cli_abort
50
+ from ggh4x._facet_helpers import reshape_add_margins
51
+ from ggh4x._facet_utils import df_grid
52
+ from ggh4x.facet_grid2 import FacetGrid2, _as_name_list, new_grid_facets
53
+ from ggh4x.strip_nested import strip_nested
54
+
55
+ # Importing this module registers the ``ggh4x.facet.nestline`` theme element.
56
+ import ggh4x.themes_ggh4x # noqa: F401
57
+
58
+ __all__ = [
59
+ "facet_nested",
60
+ "FacetNested",
61
+ "add_nest_indicator",
62
+ ]
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Borrowed ggplot2 internals (eval_facets / join_keys) -- not exported by the
67
+ # sibling ports, so reimplemented here on resolved column-name lists.
68
+ # ---------------------------------------------------------------------------
69
+ def _eval_facets(
70
+ facets: Sequence[str],
71
+ data: pd.DataFrame,
72
+ possible_columns: Optional[Sequence[str]] = None,
73
+ ) -> pd.DataFrame:
74
+ """Evaluate the faceting variables against a layer's data.
75
+
76
+ Port of ggplot2's ``eval_facets`` (``borrowed_ggplot2.R:297-327``) reduced to
77
+ the resolved-column-name model: each name in *facets* that names a column of
78
+ *data* yields that column; names absent from *data* are dropped (so a layer
79
+ missing some faceting variables simply contributes fewer columns).
80
+
81
+ Parameters
82
+ ----------
83
+ facets : sequence of str
84
+ Resolved faceting-variable names (e.g. ``["vs", "cyl"]``).
85
+ data : pandas.DataFrame
86
+ A single layer's data frame.
87
+ possible_columns : sequence of str, optional
88
+ The union of all layers' column names (R ``.possible_columns``); used only
89
+ to mirror the R signature -- evaluation here is purely by membership in
90
+ ``data.columns``.
91
+
92
+ Returns
93
+ -------
94
+ pandas.DataFrame
95
+ One column per evaluated facet, in *facets* order, length ``len(data)``.
96
+ """
97
+ cols: Dict[str, Any] = {}
98
+ for f in facets:
99
+ if isinstance(data, pd.DataFrame) and f in data.columns:
100
+ cols[f] = data[f].to_numpy()
101
+ return pd.DataFrame(cols, index=range(len(data)) if isinstance(data, pd.DataFrame) else None)
102
+
103
+
104
+ def _join_keys(x: pd.DataFrame, y: pd.DataFrame, by: Sequence[str]) -> Dict[str, np.ndarray]:
105
+ """Compute matchable integer keys for two frames over shared columns.
106
+
107
+ Port of ggplot2's ``join_keys`` (``borrowed_ggplot2.R:429-437``): row-bind
108
+ ``x[by]`` and ``y[by]``, assign a single stable id over the combined frame
109
+ (via :func:`ggh4x._borrowed_ggplot2.id`), then split back into the ``x`` and
110
+ ``y`` id vectors so that equal key-combinations share an id.
111
+
112
+ Parameters
113
+ ----------
114
+ x, y : pandas.DataFrame
115
+ Frames sharing the *by* columns (already factor-coerced by the caller).
116
+ by : sequence of str
117
+ The columns to key on.
118
+
119
+ Returns
120
+ -------
121
+ dict
122
+ ``{"x": ndarray, "y": ndarray, "n": int}`` -- the per-row ids of ``x`` and
123
+ ``y`` and the total number of distinct combinations.
124
+ """
125
+ by = list(by)
126
+ nx = len(x)
127
+ joint = pd.concat(
128
+ [x[by].reset_index(drop=True), y[by].reset_index(drop=True)],
129
+ ignore_index=True,
130
+ )
131
+ keys = id(joint, drop=True)
132
+ n = int(keys.n)
133
+ return {"x": np.asarray(keys[:nx]), "y": np.asarray(keys[nx:]), "n": n}
134
+
135
+
136
+ def _as_r_character(value: Any) -> Any:
137
+ """Stringify a value the way R's ``as.character`` would for facet labels.
138
+
139
+ R coerces a numeric like ``6`` (stored as a double) to ``"6"`` -- not
140
+ ``"6.0"``. Mirror that: whole-valued floats lose the trailing ``.0``; ``NaN``
141
+ / ``None`` pass through unchanged so downstream factor coercion still sees a
142
+ missing value.
143
+
144
+ Parameters
145
+ ----------
146
+ value : Any
147
+ A scalar facet value.
148
+
149
+ Returns
150
+ -------
151
+ Any
152
+ The R-style character representation (or the original value if NA).
153
+ """
154
+ if value is None or (isinstance(value, float) and np.isnan(value)):
155
+ return value
156
+ if isinstance(value, (int, np.integer)):
157
+ return str(int(value))
158
+ if isinstance(value, (float, np.floating)):
159
+ f = float(value)
160
+ return str(int(f)) if f.is_integer() else repr(f)
161
+ return str(value)
162
+
163
+
164
+ def _as_factor_addNA(series: Any) -> pd.Categorical:
165
+ """Coerce a column to a factor (a present NA handled downstream by ``id``).
166
+
167
+ Mirrors R ``addNA(as.factor(x), ifany = TRUE)`` for key matching. The
168
+ :func:`ggh4x._borrowed_ggplot2.id` used by :func:`_join_keys` already ports
169
+ ``addNA(ifany = TRUE)`` (it gives a present NA its own extra level), so this
170
+ only needs to produce a clean :class:`pandas.Categorical`, preserving an
171
+ existing categorical's level order.
172
+
173
+ Parameters
174
+ ----------
175
+ series : pandas.Series or array-like
176
+ A facet-value column.
177
+
178
+ Returns
179
+ -------
180
+ pandas.Categorical
181
+ The factor; a present NA becomes a matchable extra level via ``id``.
182
+ """
183
+ if isinstance(series, pd.Categorical):
184
+ return series
185
+ if isinstance(series, pd.Series) and isinstance(series.dtype, pd.CategoricalDtype):
186
+ return pd.Categorical(series)
187
+ return pd.Categorical(series)
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Constructor
192
+ # ---------------------------------------------------------------------------
193
+ def facet_nested(
194
+ rows: Any = None,
195
+ cols: Any = None,
196
+ scales: Any = "fixed",
197
+ space: Any = "fixed",
198
+ axes: Any = "margins",
199
+ remove_labels: Any = "none",
200
+ independent: Any = "none",
201
+ shrink: bool = True,
202
+ labeller: Any = "label_value",
203
+ as_table: bool = True,
204
+ switch: Optional[str] = None,
205
+ drop: bool = True,
206
+ margins: Any = False,
207
+ nest_line: Any = None,
208
+ solo_line: bool = False,
209
+ resect: Any = None,
210
+ render_empty: bool = True,
211
+ strip: Any = strip_nested,
212
+ bleed: Optional[bool] = None,
213
+ ) -> "FacetNested":
214
+ """Layout panels in a grid with nested strips.
215
+
216
+ Port of ggh4x's ``facet_nested()`` (``R/facet_nested.R:60-120``). Inherits the
217
+ capabilities of :func:`ggh4x.facet_grid2` and adds label-merged nested strips
218
+ plus hierarchy ``nest_line``s. Unlike ``facet_grid()`` it only auto-expands a
219
+ missing faceting variable when there is no variable in that direction
220
+ (allowing partially faceted layers); at least one layer must still contain all
221
+ faceting variables.
222
+
223
+ Parameters
224
+ ----------
225
+ rows, cols : formula / list / dict / None
226
+ Faceting variables for rows and columns. Variable order encodes the
227
+ hierarchy: the first is the outermost (furthest from the panels).
228
+ scales : {"fixed", "free_x", "free_y", "free"} or bool, default "fixed"
229
+ space : {"fixed", "free_x", "free_y", "free"} or bool, default "fixed"
230
+ axes : {"margins", "x", "y", "all"} or bool, default "margins"
231
+ remove_labels : {"none", "x", "y", "all"} or bool, default "none"
232
+ independent : {"none", "x", "y", "all"} or bool, default "none"
233
+ shrink : bool, default True
234
+ labeller : callable or str, default "label_value"
235
+ as_table : bool, default True
236
+ switch : {"x", "y", "both", None}, default None
237
+ drop : bool, default True
238
+ margins : bool or list of str, default False
239
+ nest_line : ElementLine / ElementBlank / bool / None, default None
240
+ The hierarchy line element. ``None`` is treated as R's default
241
+ ``element_line(inherit_blank=True)`` (so it inherits the (blank) theme
242
+ element ``ggh4x.facet.nestline`` and draws nothing unless the theme turns
243
+ it on). ``True`` -> ``element_line()``; ``False`` -> ``element_blank()``.
244
+ solo_line : bool, default False
245
+ Draw nest lines on single-child parent strips too (``True``) or only on
246
+ multi-child parents (``False``).
247
+ resect : Unit or None, default None
248
+ How much to shorten each nest line at both ends. ``None`` -> ``0 mm``.
249
+ render_empty : bool, default True
250
+ strip : Strip or callable or str, default :func:`ggh4x.strip_nested.strip_nested`
251
+ The strip specification (defaults to the nested label-merging strip).
252
+ bleed : bool or None, default None
253
+ Deprecated. When given, emits a ``DeprecationWarning`` and forwards to the
254
+ resolved strip's ``bleed`` param (set it via ``strip_nested(bleed=...)``).
255
+
256
+ Returns
257
+ -------
258
+ FacetNested
259
+ A ggproto facet object that can be added to a plot.
260
+ """
261
+ from ggh4x.strip_vanilla import resolve_strip
262
+
263
+ strip = resolve_strip(strip)
264
+ if bleed is not None:
265
+ warnings.warn(
266
+ "The `bleed` argument of `facet_nested()` is deprecated as of ggh4x "
267
+ "0.2.0. The `bleed` argument should be set in the `strip_nested()` "
268
+ "function instead.",
269
+ DeprecationWarning,
270
+ stacklevel=2,
271
+ )
272
+ strip.params["bleed"] = bool(bleed)
273
+
274
+ nest_line = _coerce_nest_line(nest_line)
275
+ if resect is None:
276
+ resect = Unit(0, "mm")
277
+
278
+ params = {
279
+ "nest_line": nest_line,
280
+ "solo_line": bool(solo_line),
281
+ "resect": resect,
282
+ }
283
+
284
+ return new_grid_facets(
285
+ rows,
286
+ cols,
287
+ scales,
288
+ space,
289
+ axes,
290
+ remove_labels,
291
+ independent,
292
+ shrink,
293
+ labeller,
294
+ as_table,
295
+ switch,
296
+ drop,
297
+ margins,
298
+ render_empty,
299
+ strip,
300
+ params=params,
301
+ super_=FacetNested,
302
+ )
303
+
304
+
305
+ def _coerce_nest_line(nest_line: Any) -> Any:
306
+ """Normalise the ``nest_line`` argument to an element (R ``facet_nested.R:91-104``).
307
+
308
+ ``None`` reproduces R's constructor default ``element_line(inherit_blank =
309
+ TRUE)``; ``True`` -> ``element_line()``; ``False`` -> ``element_blank()``. Any
310
+ other value must be an :class:`ElementLine` or :class:`ElementBlank`, else an
311
+ error is raised.
312
+
313
+ Parameters
314
+ ----------
315
+ nest_line : ElementLine / ElementBlank / bool / None
316
+ The user-supplied nest-line argument.
317
+
318
+ Returns
319
+ -------
320
+ Element
321
+ A validated line / blank element.
322
+ """
323
+ if nest_line is None:
324
+ return element_line(inherit_blank=True)
325
+ if nest_line is True:
326
+ return element_line()
327
+ if nest_line is False:
328
+ return element_blank()
329
+ if not (
330
+ is_theme_element(nest_line, "line") or is_theme_element(nest_line, "blank")
331
+ ):
332
+ cli_abort(
333
+ "The `nest_line` argument must be `element_blank` or inherit from "
334
+ "`element_line`."
335
+ )
336
+ return nest_line
337
+
338
+
339
+ # ---------------------------------------------------------------------------
340
+ # ggproto
341
+ # ---------------------------------------------------------------------------
342
+ class FacetNested(FacetGrid2):
343
+ """Nested-strip grid facet ggproto (port of R ``FacetNested``).
344
+
345
+ Subclasses :class:`ggh4x.facet_grid2.FacetGrid2`. Overrides ``map_data`` (the
346
+ per-direction "missing only when no var in that direction" rule),
347
+ ``vars_combine`` (blank-fill absent columns instead of erroring) and
348
+ ``finish_panels`` (draw nest indicator lines).
349
+
350
+ Attributes
351
+ ----------
352
+ shrink : bool
353
+ strip : Strip
354
+ Defaults to a :class:`ggh4x.strip_nested.StripNested`.
355
+ params : dict
356
+ Adds ``nest_line``, ``solo_line``, ``resect`` to the ``FacetGrid2`` params.
357
+ """
358
+
359
+ _class_name = "FacetNested"
360
+
361
+ # -- map_data -----------------------------------------------------------
362
+ def map_data(
363
+ self,
364
+ data: pd.DataFrame,
365
+ layout: pd.DataFrame,
366
+ params: Dict[str, Any],
367
+ ) -> pd.DataFrame:
368
+ """Assign data rows to panels with the nesting missing-var rule.
369
+
370
+ Port of R ``FacetNested$map_data`` (``facet_nested.R:130-187``). Differs
371
+ from the stock :meth:`ggplot2_py.facet.FacetGrid.map_data`: a faceting
372
+ variable is only treated as missing (and force-expanded across panels)
373
+ when *none* of the variables in its direction (rows / cols) are present in
374
+ the layer, which is what lets a partially faceted layer nest.
375
+
376
+ Parameters
377
+ ----------
378
+ data : pandas.DataFrame
379
+ A single layer's data.
380
+ layout : pandas.DataFrame
381
+ The panel layout (carries ``PANEL`` + the faceting-var columns).
382
+ params : dict
383
+ Facet params (``rows``, ``cols``, ``margins``, ``_possible_columns``).
384
+
385
+ Returns
386
+ -------
387
+ pandas.DataFrame
388
+ *data* with an integer/categorical ``PANEL`` column.
389
+ """
390
+ if empty(data):
391
+ out = data.copy() if isinstance(data, pd.DataFrame) else pd.DataFrame()
392
+ out["PANEL"] = pd.Series([], dtype="int64")
393
+ return out
394
+
395
+ rows = params.get("rows")
396
+ cols = params.get("cols")
397
+ row_names = _as_name_list(rows)
398
+ col_names = _as_name_list(cols)
399
+ vars_ = row_names + col_names
400
+
401
+ if len(vars_) == 0:
402
+ data = data.copy()
403
+ # R: data$PANEL <- layout$PANEL (recycles the single layout PANEL
404
+ # across all data rows when there are no faceting variables).
405
+ panel_vals = list(layout["PANEL"])
406
+ n = len(data)
407
+ if len(panel_vals) and n:
408
+ data["PANEL"] = [panel_vals[i % len(panel_vals)] for i in range(n)]
409
+ else:
410
+ data["PANEL"] = panel_vals
411
+ return data
412
+
413
+ possible_columns = params.get("_possible_columns")
414
+ margin_vars = [
415
+ [c for c in row_names if c in data.columns],
416
+ [c for c in col_names if c in data.columns],
417
+ ]
418
+
419
+ data = reshape_add_margins(data, margin_vars, params.get("margins", False))
420
+ facet_vals = _eval_facets(vars_, data, possible_columns)
421
+
422
+ # Only set as missing if it has no variable in that direction.
423
+ missing_facets: List[str] = []
424
+ if not any(r in facet_vals.columns for r in row_names):
425
+ missing_facets += [r for r in row_names if r not in facet_vals.columns]
426
+ if not any(c in facet_vals.columns for c in col_names):
427
+ missing_facets += [c for c in col_names if c not in facet_vals.columns]
428
+
429
+ if len(missing_facets) > 0:
430
+ to_add = layout[missing_facets].drop_duplicates().reset_index(drop=True)
431
+ n_data = len(data)
432
+ n_add = len(to_add)
433
+ # data_rep = rep.int(1:nrow(data), nrow(to_add)) (data fastest)
434
+ data_rep = np.tile(np.arange(n_data), n_add)
435
+ # facet_rep = rep(1:nrow(to_add), each = nrow(data))
436
+ facet_rep = np.repeat(np.arange(n_add), n_data)
437
+ data = data.iloc[data_rep].reset_index(drop=True)
438
+ facet_vals = facet_vals.iloc[data_rep].reset_index(drop=True)
439
+ add_block = to_add.iloc[facet_rep].reset_index(drop=True)
440
+ facet_vals = pd.concat([facet_vals, add_block], axis=1)
441
+
442
+ data = data.copy()
443
+ if len(facet_vals) == 0:
444
+ data["PANEL"] = -1
445
+ return data
446
+
447
+ # ``by`` = vars that appear in facet_vals (intersection, in vars order).
448
+ by = [v for v in vars_ if v in facet_vals.columns and v in layout.columns]
449
+ # Factor-coerce + addNA on both sides (R: lapply(.., as.factor) then
450
+ # addNA(ifany=TRUE)). R's ``as.factor`` stringifies levels, so numeric
451
+ # facet values (``6``) on one side match the blank-filled character
452
+ # values (``"6"`` / ``""``) on the other -- normalise via
453
+ # :func:`_as_r_character` before factoring so both key columns align.
454
+ fv = facet_vals.copy()
455
+ lay = layout.copy()
456
+ for c in by:
457
+ fv[c] = _as_factor_addNA(fv[c].map(_as_r_character))
458
+ lay[c] = _as_factor_addNA(lay[c].map(_as_r_character))
459
+ keys = _join_keys(fv, lay, by=by)
460
+ # PANEL = layout$PANEL[match(keys$x, keys$y)]
461
+ panel_lookup: Dict[int, Any] = {}
462
+ layout_panel = list(layout["PANEL"])
463
+ for j, ky in enumerate(keys["y"]):
464
+ panel_lookup.setdefault(int(ky), layout_panel[j])
465
+ matched = [panel_lookup.get(int(kx)) for kx in keys["x"]]
466
+ data["PANEL"] = matched
467
+ return data
468
+
469
+ # -- vars_combine -------------------------------------------------------
470
+ def vars_combine(
471
+ self,
472
+ data: List[pd.DataFrame],
473
+ env: Any = None,
474
+ vars_: Any = None,
475
+ drop: bool = True,
476
+ ) -> pd.DataFrame:
477
+ """Combine faceting variables, blank-filling layers missing some vars.
478
+
479
+ Port of R ``FacetNested$vars_combine`` (``facet_nested.R:188-234``).
480
+ Builds the cross-product base from layers that provide *all* requested
481
+ variables (at least one must), then for each partial layer appends the
482
+ grid of its present-variable values against the base's other columns with
483
+ those absent columns set to the empty string ``""`` (line 227 -- the
484
+ divergence from vanilla ``combine_vars``).
485
+
486
+ Parameters
487
+ ----------
488
+ data : list of DataFrame
489
+ The plot + layer data frames.
490
+ env : Any, optional
491
+ Unused (kept for R signature parity).
492
+ vars_ : dict or list of str
493
+ The faceting-variable names for this direction.
494
+ drop : bool, default True
495
+ Drop unused factor combinations.
496
+
497
+ Returns
498
+ -------
499
+ pandas.DataFrame
500
+ Unique combinations (with blank-filled partial-layer rows).
501
+ """
502
+ names = _as_name_list(vars_)
503
+ if len(names) == 0:
504
+ return pd.DataFrame()
505
+
506
+ possible_columns = sorted(
507
+ {c for df in data if isinstance(df, pd.DataFrame) for c in df.columns}
508
+ )
509
+
510
+ values: List[pd.DataFrame] = []
511
+ for df in data:
512
+ v = _eval_facets(names, df, possible_columns)
513
+ if v.shape[1] > 0:
514
+ values.append(v)
515
+
516
+ has_all = [v.shape[1] == len(names) for v in values]
517
+ if not any(has_all):
518
+ missing_per = []
519
+ for v in values:
520
+ missing_per.append([n for n in names if n not in v.columns])
521
+ detail = "; ".join(
522
+ f"layer {i} is missing {m}" for i, m in enumerate(missing_per)
523
+ )
524
+ cli_abort(
525
+ "At least one layer must contain all faceting variables: "
526
+ f"{names}. {detail}"
527
+ )
528
+
529
+ base = (
530
+ pd.concat([v for v, h in zip(values, has_all) if h], ignore_index=True)
531
+ .drop_duplicates()
532
+ .reset_index(drop=True)
533
+ )
534
+ base = base[names]
535
+ if not drop:
536
+ base = unique_combs(base)
537
+
538
+ for v, h in zip(values, has_all):
539
+ if h or empty(v):
540
+ continue
541
+ present = [c for c in base.columns if c in v.columns]
542
+ absent = [c for c in base.columns if c not in v.columns]
543
+ # new = unique(value[intersect(names(base), names(value))]) (R:222).
544
+ new = v[present].drop_duplicates().reset_index(drop=True)
545
+ if drop:
546
+ new = unique_combs(new)
547
+ # old = base[setdiff(names(base), names(value))] -- ALL base rows of
548
+ # the absent columns (NOT deduplicated, R:221), blank-filled to ""
549
+ # (R:227). R's assignment also coerces the whole column to character;
550
+ # cast the absent columns of ``base`` to object so the rbind below
551
+ # yields a uniform string column (matching R's coercion).
552
+ old = base[absent].reset_index(drop=True)
553
+ for c in absent:
554
+ old[c] = ""
555
+ # R's ``old[...] <- ""`` coerces the whole column to character,
556
+ # which then propagates to ``base`` through ``rbind``; mirror that
557
+ # by stringifying the existing (e.g. numeric) ``base`` values so
558
+ # the concatenated column is a uniform character vector.
559
+ base[c] = base[c].map(_as_r_character)
560
+ grid = df_grid(old, new)
561
+ base = pd.concat([base, grid[base.columns]], ignore_index=True)
562
+
563
+ # R does NOT deduplicate after the partial-layer rbind (facet_nested.R:228);
564
+ # the downstream ``compute_layout`` collapses duplicates. Keep parity.
565
+ base = base.reset_index(drop=True)
566
+ if empty(base):
567
+ cli_abort("Facetting variables must have at least one value.")
568
+ return base
569
+
570
+ # -- finish_panels ------------------------------------------------------
571
+ def finish_panels(
572
+ self,
573
+ panels: Any,
574
+ layout: pd.DataFrame,
575
+ params: Dict[str, Any],
576
+ theme: Any,
577
+ ) -> Any:
578
+ """Draw the nest indicator lines onto the assembled panel table.
579
+
580
+ Port of R ``FacetNested$finish_panels`` (``facet_nested.R:236-238``);
581
+ delegates to :func:`add_nest_indicator`.
582
+
583
+ Parameters
584
+ ----------
585
+ panels : Gtable
586
+ The assembled panel gtable.
587
+ layout : pandas.DataFrame
588
+ params : dict
589
+ theme : Theme
590
+
591
+ Returns
592
+ -------
593
+ Gtable
594
+ The panel gtable with nest-line ``"nester"`` grobs added.
595
+ """
596
+ return add_nest_indicator(panels, params, theme)
597
+
598
+
599
+ # ---------------------------------------------------------------------------
600
+ # Nest-indicator helper (shared by FacetNested + FacetNestedWrap)
601
+ # ---------------------------------------------------------------------------
602
+ def _layout_df(table: Any) -> pd.DataFrame:
603
+ """Return a gtable layout (dict-of-lists) as a DataFrame with a 1-based index.
604
+
605
+ Adds an ``index`` column equal to the 1-based position of each layout entry
606
+ (R ``layout$index <- seq_len(nrow(layout))``), so the positional index can be
607
+ carried through the ``startswith("strip-")`` filter back to ``panels.grobs`` /
608
+ ``panels.layout`` (which remain dict-of-lists / list and are mutated in place).
609
+
610
+ Parameters
611
+ ----------
612
+ table : Gtable
613
+
614
+ Returns
615
+ -------
616
+ pandas.DataFrame
617
+ The layout with an added 1-based integer ``index`` column.
618
+ """
619
+ lay = table.layout
620
+ df = pd.DataFrame({k: list(v) for k, v in lay.items()})
621
+ df["index"] = np.arange(1, len(df) + 1)
622
+ return df
623
+
624
+
625
+ def add_nest_indicator(panels: Any, params: Dict[str, Any], theme: Any) -> Any:
626
+ """Draw hierarchy nest lines between strip layers of an assembled panel table.
627
+
628
+ Faithful port of ggh4x's ``add_nest_indicator`` (``facet_nested.R:243-354``).
629
+ Resolves the ``nest_line`` element (inheriting from the theme's
630
+ ``ggh4x.facet.nestline``); returns *panels* unchanged when it is ``None`` /
631
+ ``False`` / blank. Otherwise, for the horizontal (top/bottom) and vertical
632
+ (left/right) strips it draws a shortened polyline (``resect``) along the inner
633
+ edge of every *parent* (multi-child, ``l != r`` / ``t != b``) strip -- or, when
634
+ ``solo_line`` is set, every strip except the innermost layer -- by adding a
635
+ ``"nester"`` grob onto that strip's sub-gtable. It then bumps
636
+ ``panels.layout['z']`` by the exact z-offset (R lines 300-307 / 343-350) so the
637
+ line-carrying strip layer paints above the lower strip layers.
638
+
639
+ Parameters
640
+ ----------
641
+ panels : Gtable
642
+ The assembled panel gtable; its ``strip-*`` cells must be per-layer
643
+ sub-gtables (as produced by the nested strip subsystem).
644
+ params : dict
645
+ Facet params; reads ``nest_line``, ``solo_line``, ``resect``.
646
+ theme : Theme
647
+ The resolved plot theme (for ``ggh4x.facet.nestline``).
648
+
649
+ Returns
650
+ -------
651
+ Gtable
652
+ *panels* with the nest-line grobs added and z-order adjusted.
653
+ """
654
+ nest_line = params.get("nest_line")
655
+ if nest_line is None or nest_line is False:
656
+ return panels
657
+ nest_line = combine_elements(nest_line, calc_element("ggh4x.facet.nestline", theme))
658
+ if is_theme_element(nest_line, "blank") or isinstance(nest_line, ElementBlank):
659
+ return panels
660
+ solo = bool(params.get("solo_line"))
661
+
662
+ # Locate strips (1-based ``index`` carried through the filter).
663
+ layout = _layout_df(panels)
664
+ names = layout["name"].astype(str)
665
+ is_strip = names.str.startswith("strip-")
666
+ layout = layout[is_strip].reset_index(drop=True)
667
+
668
+ resect = params.get("resect")
669
+ if resect is None:
670
+ resect = Unit(0, "mm")
671
+ # active = unit(c(0, 1), "npc") + c(1, -1) * resect
672
+ active = Unit([0, 1], "npc") + unit_c(1.0 * resect, -1.0 * resect)
673
+
674
+ # -- Horizontal (top/bottom) strips -------------------------------------
675
+ h_strip = layout
676
+ if not solo:
677
+ h_strip = h_strip[h_strip["l"] != h_strip["r"]]
678
+ else:
679
+ hn = h_strip["name"].astype(str)
680
+ h_strip = h_strip[hn.str.startswith("strip-b") | hn.str.startswith("strip-t")]
681
+ if len(h_strip) > 0:
682
+ index = [int(i) for i in h_strip["index"]]
683
+ is_secondary = bool(
684
+ h_strip["name"].astype(str).str.startswith("strip-b").any()
685
+ )
686
+ passive = [float(is_secondary), float(is_secondary)]
687
+ indicator = element_grob(
688
+ nest_line, x=active, y=passive, default_units="npc"
689
+ )
690
+
691
+ if solo:
692
+ kept: List[int] = []
693
+ for idx in index:
694
+ gt = panels.grobs[idx - 1]
695
+ pos = 1 if is_secondary else int(gt.shape[0])
696
+ if int(gt.layout["t"][0]) != pos:
697
+ kept.append(idx)
698
+ index = kept
699
+
700
+ for idx in index:
701
+ gt = panels.grobs[idx - 1]
702
+ s = {k: gt.layout[k][0] for k in ("t", "l", "r", "b", "z")}
703
+ gt = gtable_add_grob(
704
+ gt,
705
+ indicator,
706
+ t=int(s["t"]),
707
+ l=int(s["l"]),
708
+ b=int(s["b"]),
709
+ r=int(s["r"]),
710
+ z=s["z"],
711
+ name="nester",
712
+ clip="off",
713
+ )
714
+ panels.grobs[idx - 1] = gt
715
+
716
+ if index:
717
+ offset = [int(panels.grobs[idx - 1].layout["t"][0]) for idx in index]
718
+ if not is_secondary:
719
+ nlevels = int(panels.grobs[index[0] - 1].shape[0])
720
+ offset = [nlevels - o for o in offset]
721
+ z_list = panels.layout["z"]
722
+ for idx, off in zip(index, offset):
723
+ z_list[idx - 1] = z_list[idx - 1] + off
724
+
725
+ # -- Vertical (left/right) strips ---------------------------------------
726
+ v_strip = layout
727
+ if not solo:
728
+ v_strip = v_strip[v_strip["t"] != v_strip["b"]]
729
+ else:
730
+ vn = v_strip["name"].astype(str)
731
+ v_strip = v_strip[vn.str.startswith("strip-r") | vn.str.startswith("strip-l")]
732
+ if len(v_strip) > 0:
733
+ index = [int(i) for i in v_strip["index"]]
734
+ is_secondary = bool(
735
+ v_strip["name"].astype(str).str.startswith("strip-r").any()
736
+ )
737
+ passive = [float(not is_secondary), float(not is_secondary)]
738
+ indicator = element_grob(
739
+ nest_line, x=passive, y=active, default_units="npc"
740
+ )
741
+
742
+ if solo:
743
+ kept = []
744
+ for idx in index:
745
+ gt = panels.grobs[idx - 1]
746
+ pos = 1 if is_secondary else int(gt.shape[1])
747
+ if int(gt.layout["l"][0]) != pos:
748
+ kept.append(idx)
749
+ index = kept
750
+
751
+ for idx in index:
752
+ gt = panels.grobs[idx - 1]
753
+ s = {k: gt.layout[k][0] for k in ("t", "l", "r", "b", "z")}
754
+ gt = gtable_add_grob(
755
+ gt,
756
+ indicator,
757
+ t=int(s["t"]),
758
+ l=int(s["l"]),
759
+ b=int(s["b"]),
760
+ r=int(s["r"]),
761
+ z=s["z"],
762
+ name="nester",
763
+ clip="off",
764
+ )
765
+ panels.grobs[idx - 1] = gt
766
+
767
+ if index:
768
+ offset = [int(panels.grobs[idx - 1].layout["l"][0]) for idx in index]
769
+ if not is_secondary:
770
+ nlevels = int(panels.grobs[index[0] - 1].shape[1])
771
+ offset = [nlevels - o for o in offset]
772
+ z_list = panels.layout["z"]
773
+ for idx, off in zip(index, offset):
774
+ z_list[idx - 1] = z_list[idx - 1] + off
775
+
776
+ return panels