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_manual.py ADDED
@@ -0,0 +1,901 @@
1
+ """Manual panel layout facets (port of ggh4x ``R/facet_manual.R``).
2
+
3
+ ``facet_manual`` lays panels out according to a user *design* (a character
4
+ art-string or an integer matrix), letting panels span arbitrary rectangles of a
5
+ cell grid. :class:`FacetManual` subclasses the ggh4x :class:`~ggh4x.facet_wrap2.FacetWrap2`
6
+ sibling (transitively :class:`ggplot2_py.facet.FacetWrap`), inheriting its
7
+ ``setup_panel_table`` / ``finish_panels`` but fully overriding ``compute_layout``
8
+ (producing a span layout with ``.TOP``/``.RIGHT``/``.BOTTOM``/``.LEFT`` instead
9
+ of ``ROW``/``COL``), ``map_data``, ``setup_aspect_ratio``, ``setup_axes``,
10
+ ``attach_axes`` and ``draw_panels``.
11
+
12
+ The design matrix is a 2-D NumPy integer array of 1-based panel ids with
13
+ ``np.nan`` for blank cells, carrying a sidecar ``design_names`` list when the
14
+ design was character-coded with non-numeric labels.
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 _combine_vars, _resolve_facet_vars, facet_null, facet_wrap
26
+ from grid_py import Unit, is_unit, null_grob, unit_rep
27
+
28
+ from ggh4x._borrowed_ggplot2 import empty, id, snake_class
29
+ from ggh4x._cli import cli_abort, cli_warn
30
+ from ggh4x._facet_helpers import AspectRatio, _match_facet_arg
31
+ from ggh4x._facet_utils import (
32
+ render_axes,
33
+ split_heights_cm,
34
+ split_widths_cm,
35
+ weave_panel_cols,
36
+ weave_panel_rows,
37
+ )
38
+ from ggh4x._rlang import arg_match0
39
+ from ggh4x.facet_wrap2 import FacetWrap2, purge_guide_labels
40
+ from ggh4x.strip_vanilla import resolve_strip
41
+
42
+ __all__ = [
43
+ "facet_manual",
44
+ "FacetManual",
45
+ "_validate_design",
46
+ "_restrict_axes",
47
+ "_do_purge",
48
+ ]
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # validate_design (R facet_manual.R:381-437)
53
+ # ---------------------------------------------------------------------------
54
+ class _Design:
55
+ """A validated design: an integer matrix plus optional ``design_names``.
56
+
57
+ Stands in for R's ``matrix`` with ``attr(., "design_names")``. Blank cells
58
+ are ``np.nan`` (the matrix is stored as float to admit ``nan``); ``names`` is
59
+ the sorted unique character labels when the design was character-coded, else
60
+ ``None``.
61
+
62
+ Attributes
63
+ ----------
64
+ matrix : numpy.ndarray
65
+ 2-D float array of 1-based panel ids (``nan`` = blank).
66
+ names : list or None
67
+ The ``design_names`` sidecar.
68
+ """
69
+
70
+ def __init__(self, matrix: np.ndarray, names: Optional[List[Any]] = None) -> None:
71
+ self.matrix = matrix
72
+ self.names = names
73
+
74
+ @property
75
+ def shape(self) -> Any:
76
+ """Return the design matrix shape ``(nrow, ncol)``."""
77
+ return self.matrix.shape
78
+
79
+
80
+ def _validate_design(design: Any = None, trim: bool = True) -> _Design:
81
+ """Validate / normalise a *design* into an integer :class:`_Design`.
82
+
83
+ Faithful port of ggh4x's ``validate_design`` (``R/facet_manual.R:381-437``).
84
+ Character designs (patchwork-style art-strings) are split on newlines,
85
+ trimmed, and split per character (``'#'`` -> blank). Matrices use ``np.nan``
86
+ for blanks. The unique non-blank values are sorted and renumbered to
87
+ ``1..k``; non-numeric labels are kept as ``design_names``. When ``trim`` is
88
+ ``True`` the design is cropped to its non-empty rows / columns.
89
+
90
+ Parameters
91
+ ----------
92
+ design : str or array-like or None
93
+ The design specification.
94
+ trim : bool, default True
95
+ Whether to trim empty rows / columns.
96
+
97
+ Returns
98
+ -------
99
+ _Design
100
+ The validated design.
101
+
102
+ Raises
103
+ ------
104
+ ValueError
105
+ When *design* is ``None``, non-rectangular, of invalid dimensions, or
106
+ not interpretable as a matrix.
107
+ """
108
+ if design is None:
109
+ cli_abort("The `design` argument cannot be `None`.")
110
+
111
+ names: Optional[List[Any]] = None
112
+
113
+ # --- character art-string path (patchwork::as_areas) --------------------
114
+ if isinstance(design, str):
115
+ lines = design.split("\n")
116
+ lines = [s.strip() for s in lines]
117
+ lines = [s for s in lines if len(s) > 0]
118
+ rows = [list(s) for s in lines]
119
+ ncols = [len(r) for r in rows]
120
+ if len(set(ncols)) != 1:
121
+ cli_abort("The `design` argument must be rectangular.")
122
+ mat = np.array(rows, dtype=object)
123
+ else:
124
+ mat = np.asarray(design)
125
+ if mat.ndim != 2:
126
+ # Force to a column matrix (R as.matrix on an atomic vector).
127
+ mat = mat.reshape(-1, 1)
128
+
129
+ if mat.ndim != 2 or any(d < 1 for d in mat.shape):
130
+ cli_abort("The `design` argument has invalid dimensions.")
131
+
132
+ dim = mat.shape
133
+
134
+ # Character matrices: '#' -> blank (NA).
135
+ is_char = mat.dtype.kind in ("U", "S", "O") and any(
136
+ isinstance(v, str) for v in mat.flatten()
137
+ )
138
+ flat = mat.flatten()
139
+ if is_char:
140
+ flat = np.array(
141
+ [np.nan if (isinstance(v, str) and v == "#") else v for v in flat],
142
+ dtype=object,
143
+ )
144
+
145
+ # uniq = sort(unique(design)) over non-blank values.
146
+ def _is_blank(v: Any) -> bool:
147
+ if v is None:
148
+ return True
149
+ try:
150
+ return bool(np.isnan(v))
151
+ except (TypeError, ValueError):
152
+ return False
153
+
154
+ non_blank = [v for v in flat if not _is_blank(v)]
155
+ uniq = sorted(set(non_blank), key=lambda z: (str(type(z)), z))
156
+ # design = match(design, uniq) -> 1-based ids, nan for blanks.
157
+ lookup = {v: i + 1 for i, v in enumerate(uniq)}
158
+ matched = np.array(
159
+ [float(lookup[v]) if not _is_blank(v) else np.nan for v in flat],
160
+ dtype=float,
161
+ ).reshape(dim)
162
+
163
+ if trim:
164
+ non_empty = ~np.isnan(matched)
165
+ row_any = np.where(non_empty.any(axis=1))[0]
166
+ col_any = np.where(non_empty.any(axis=0))[0]
167
+ if len(row_any):
168
+ keep_row = list(range(int(row_any.min()), int(row_any.max()) + 1))
169
+ else:
170
+ keep_row = list(range(dim[0]))
171
+ if len(col_any):
172
+ keep_col = list(range(int(col_any.min()), int(col_any.max()) + 1))
173
+ else:
174
+ keep_col = list(range(dim[1]))
175
+ matched = matched[np.ix_(keep_row, keep_col)]
176
+
177
+ # Non-numeric uniques become design_names.
178
+ numeric_uniq = all(
179
+ isinstance(v, (int, float, np.integer, np.floating)) for v in uniq
180
+ )
181
+ if not numeric_uniq:
182
+ names = list(uniq)
183
+
184
+ return _Design(matched, names=names)
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # restrict_axes (R facet_manual.R:442-453)
189
+ # ---------------------------------------------------------------------------
190
+ def _restrict_axes(
191
+ axes: List[Any],
192
+ position: Sequence[int],
193
+ by: Sequence[int],
194
+ which_fun: Any = min,
195
+ restrictor: Any = null_grob,
196
+ ) -> List[Any]:
197
+ """Keep only the edge-most axis per group, blanking / purging the rest.
198
+
199
+ Faithful port of ggh4x's ``restrict_axes`` (``R/facet_manual.R:442-453``).
200
+ Groups *axes* by *by*; within each group only the grob whose *position*
201
+ equals ``which_fun(group)`` is kept. Non-kept grobs are replaced: if
202
+ *restrictor* is callable it is applied per grob (e.g. ``purge_guide_labels``);
203
+ otherwise the value (e.g. ``null_grob()``) is assigned.
204
+
205
+ Parameters
206
+ ----------
207
+ axes : list of grob
208
+ One rendered axis per panel.
209
+ position : sequence of int
210
+ Per-panel ``.TOP`` / ``.BOTTOM`` / ``.LEFT`` / ``.RIGHT`` span coordinate.
211
+ by : sequence of int
212
+ Grouping coordinate (the orthogonal span coordinate).
213
+ which_fun : callable, default min
214
+ ``min`` (keep the smallest position) or ``max`` (keep the largest).
215
+ restrictor : callable or grob, default null_grob
216
+ Per-grob replacement function or replacement value.
217
+
218
+ Returns
219
+ -------
220
+ list of grob
221
+ The restricted axis list.
222
+ """
223
+ axes = list(axes)
224
+ position = list(position)
225
+ by = list(by)
226
+ n = len(axes)
227
+
228
+ # keep[i] = (position[i] == which_fun(positions in same group))
229
+ group_target: Dict[Any, Any] = {}
230
+ grouped: Dict[Any, List[int]] = {}
231
+ for i, g in enumerate(by):
232
+ grouped.setdefault(g, []).append(i)
233
+ for g, idxs in grouped.items():
234
+ group_target[g] = which_fun([position[i] for i in idxs])
235
+
236
+ keep = [position[i] == group_target[by[i]] for i in range(n)]
237
+
238
+ is_callable = callable(restrictor)
239
+ for i in range(n):
240
+ if not keep[i]:
241
+ if is_callable:
242
+ axes[i] = restrictor(axes[i])
243
+ else:
244
+ axes[i] = restrictor() if callable(restrictor) else restrictor
245
+ return axes
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # do_purge (R facet_manual.R:455-466)
250
+ # ---------------------------------------------------------------------------
251
+ def _do_purge(a: Sequence[Any], b: Sequence[Any], check_disjoint: bool = False) -> bool:
252
+ """Decide whether axes can be purged across a span layout.
253
+
254
+ Faithful port of ggh4x's ``do_purge`` (``R/facet_manual.R:455-466``). Takes
255
+ the unique ``(a, b)`` pairs; returns ``True`` when those pairs are 1:1 with
256
+ both ``a`` and ``b`` (each appears once). When *check_disjoint* and more than
257
+ one pair exists, additionally requires the spans (ordered by ``(a, b)``) to be
258
+ non-overlapping (``cummax([0, b[:-1]]) < a``).
259
+
260
+ Parameters
261
+ ----------
262
+ a, b : sequence
263
+ Paired span coordinates.
264
+ check_disjoint : bool, default False
265
+ Whether to additionally enforce non-overlapping spans.
266
+
267
+ Returns
268
+ -------
269
+ bool
270
+ """
271
+ df = pd.DataFrame({"a": list(a), "b": list(b)}).drop_duplicates().reset_index(drop=True)
272
+ aa = df["a"].to_numpy()
273
+ bb = df["b"].to_numpy()
274
+ n = len(df)
275
+ ans = n == len(pd.unique(aa)) and n == len(pd.unique(bb))
276
+ if not check_disjoint or n == 1:
277
+ return bool(ans)
278
+ order = np.lexsort((bb, aa)) # order by a, then b
279
+ a_o = aa[order]
280
+ b_o = bb[order]
281
+ cum = np.maximum.accumulate(np.concatenate([[0], b_o[:-1]]))
282
+ return bool(ans and np.all(cum < a_o))
283
+
284
+
285
+ # ---------------------------------------------------------------------------
286
+ # Constructor (R facet_manual.R:62-130)
287
+ # ---------------------------------------------------------------------------
288
+ def facet_manual(
289
+ facets: Any,
290
+ design: Any = None,
291
+ widths: Any = None,
292
+ heights: Any = None,
293
+ respect: bool = False,
294
+ drop: bool = True,
295
+ strip_position: str = "top",
296
+ scales: Any = "fixed",
297
+ axes: Any = "margins",
298
+ remove_labels: Any = "none",
299
+ labeller: Any = "label_value",
300
+ trim_blank: bool = True,
301
+ strip: Any = "vanilla",
302
+ ) -> Any:
303
+ """Manual layout for panels.
304
+
305
+ Faithful port of ggh4x's ``facet_manual`` (``R/facet_manual.R:62-130``).
306
+ Panels are placed according to *design* (a character art-string or integer
307
+ matrix), each panel spanning the bounding rectangle of its design cells.
308
+
309
+ Parameters
310
+ ----------
311
+ facets : formula / list / dict / str
312
+ Faceting variables.
313
+ design : str or array-like
314
+ Panel-area specification (``'#'`` / ``NA`` mark blank cells).
315
+ widths, heights : numeric or grid_py.Unit or None, default None
316
+ Cell sizes; numerics become relative ``"null"`` units.
317
+ respect : bool, default False
318
+ Whether ``"null"`` widths / heights are proportional.
319
+ drop : bool, default True
320
+ Drop unused factor combinations.
321
+ strip_position : {"top", "bottom", "left", "right"}, default "top"
322
+ scales : {"fixed", "free_x", "free_y", "free"} or bool, default "fixed"
323
+ axes : {"margins", "x", "y", "all"} or bool, default "margins"
324
+ remove_labels : {"none", "x", "y", "all"} or bool, default "none"
325
+ labeller : callable or str, default "label_value"
326
+ trim_blank : bool, default True
327
+ Trim empty design rows / columns.
328
+ strip : Strip or callable or str, default "vanilla"
329
+
330
+ Returns
331
+ -------
332
+ FacetManual or FacetNull
333
+ A facet ggproto object; :func:`ggplot2_py.facet.facet_null` when *facets*
334
+ is empty.
335
+ """
336
+ strip_position = arg_match0(
337
+ strip_position, ["top", "bottom", "left", "right"], arg_name="strip_position"
338
+ )
339
+
340
+ design = _validate_design(design, trim_blank)
341
+
342
+ facets_resolved = _resolve_facet_vars(facet_wrap(facets=facets).params.get("facets"))
343
+ if len(facets_resolved) == 0:
344
+ return facet_null()
345
+
346
+ if widths is not None and not is_unit(widths):
347
+ widths = Unit(widths, "null")
348
+ if heights is not None and not is_unit(heights):
349
+ heights = Unit(heights, "null")
350
+
351
+ dim = design.shape # (nrow, ncol)
352
+
353
+ if widths is not None:
354
+ widths = unit_rep(widths, length_out=dim[1])
355
+ if heights is not None:
356
+ heights = unit_rep(heights, length_out=dim[0])
357
+
358
+ free = _match_facet_arg(scales, ["fixed", "free_x", "free_y", "free"], nm="scales")
359
+ axes = _match_facet_arg(axes, ["margins", "x", "y", "all"], nm="axes")
360
+ rmlab = _match_facet_arg(remove_labels, ["none", "x", "y", "all"], nm="remove_labels")
361
+ strip = resolve_strip(strip)
362
+
363
+ params: Dict[str, Any] = {
364
+ "design": design,
365
+ "facets": {name: name for name in facets_resolved},
366
+ "widths": widths,
367
+ "heights": heights,
368
+ "respect": respect,
369
+ "strip.position": strip_position,
370
+ "strip_position": strip_position,
371
+ "labeller": labeller,
372
+ "drop": drop,
373
+ "nrow": dim[0],
374
+ "ncol": dim[1],
375
+ "free": free,
376
+ "axes": axes,
377
+ "rmlab": rmlab,
378
+ "dim": [dim[0], dim[1]],
379
+ }
380
+
381
+ obj = FacetManual()
382
+ obj._set(shrink=True, strip=strip, params=params)
383
+ return obj
384
+
385
+
386
+ # ---------------------------------------------------------------------------
387
+ # ggproto class (R facet_manual.R:138-377)
388
+ # ---------------------------------------------------------------------------
389
+ class FacetManual(FacetWrap2):
390
+ """Manual-layout facet ggproto (port of R ``FacetManual``).
391
+
392
+ Subclasses the ggh4x :class:`~ggh4x.facet_wrap2.FacetWrap2` sibling. Produces
393
+ a *span* layout (``.TOP``/``.RIGHT``/``.BOTTOM``/``.LEFT``, one row per unique
394
+ panel) rather than a ``ROW``/``COL`` grid, then weaves per-panel axes and
395
+ strips honouring those spans.
396
+
397
+ Attributes
398
+ ----------
399
+ shrink : bool
400
+ strip : Strip
401
+ params : dict
402
+ """
403
+
404
+ _class_name = "FacetManual"
405
+
406
+ shrink: bool = True
407
+ strip: Any = None
408
+
409
+ # -- compute_layout (R:141-201) -----------------------------------------
410
+ def compute_layout(self, data: List[pd.DataFrame], params: Dict[str, Any]) -> pd.DataFrame:
411
+ """Translate the design matrix into a span layout.
412
+
413
+ Port of ggh4x's ``FacetManual$compute_layout`` (``R/facet_manual.R:141-201``).
414
+
415
+ Parameters
416
+ ----------
417
+ data : list of DataFrame
418
+ params : dict
419
+
420
+ Returns
421
+ -------
422
+ pandas.DataFrame
423
+ Span layout with ``.TOP``/``.RIGHT``/``.BOTTOM``/``.LEFT``, ``PANEL``,
424
+ ``SCALE_X``, ``SCALE_Y`` and the faceting-var columns.
425
+ """
426
+ vars_ = _resolve_facet_vars(params.get("facets"))
427
+ if len(vars_) == 0:
428
+ return pd.DataFrame(
429
+ {
430
+ ".TOP": [1],
431
+ ".RIGHT": [1],
432
+ ".BOTTOM": [1],
433
+ ".LEFT": [1],
434
+ "PANEL": pd.Categorical([1]),
435
+ "SCALE_X": [1],
436
+ "SCALE_Y": [1],
437
+ }
438
+ )
439
+
440
+ design = params["design"]
441
+ mat = design.matrix
442
+ nrow, ncol = mat.shape
443
+
444
+ # split(row(design), design) -> per-id row range; same for cols.
445
+ row_idx = np.repeat(np.arange(1, nrow + 1)[:, None], ncol, axis=1)
446
+ col_idx = np.repeat(np.arange(1, ncol + 1)[None, :], nrow, axis=0)
447
+ flat_design = mat.flatten(order="F") # R fills column-major
448
+ flat_row = row_idx.flatten(order="F")
449
+ flat_col = col_idx.flatten(order="F")
450
+
451
+ # ids in first-occurrence order matching R's split() (sorted by id value).
452
+ valid = ~np.isnan(flat_design)
453
+ ids_present = sorted(set(int(v) for v in flat_design[valid]))
454
+
455
+ tops, rights, bottoms, lefts, panel_ids = [], [], [], [], []
456
+ for pid in ids_present:
457
+ mask = (flat_design == pid)
458
+ rows_g = flat_row[mask]
459
+ cols_g = flat_col[mask]
460
+ tops.append(int(rows_g.min()))
461
+ bottoms.append(int(rows_g.max()))
462
+ lefts.append(int(cols_g.min()))
463
+ rights.append(int(cols_g.max()))
464
+ panel_ids.append(pid)
465
+
466
+ layout = pd.DataFrame(
467
+ {
468
+ ".TOP": tops,
469
+ ".RIGHT": rights,
470
+ ".BOTTOM": bottoms,
471
+ ".LEFT": lefts,
472
+ "PANEL": pd.Categorical(panel_ids, categories=panel_ids),
473
+ }
474
+ )
475
+
476
+ base = _combine_vars(data, vars_, drop=params.get("drop", True))
477
+ base = base.reset_index(drop=True)
478
+ id_arr = id(base, drop=True)
479
+ n = int(id_arr.n)
480
+
481
+ if n > len(layout):
482
+ n = len(layout)
483
+ id_arr = np.asarray(id_arr)[:n]
484
+ dropped = base.apply(
485
+ lambda r: ":".join(str(x) for x in r), axis=1
486
+ ).tolist()[n:]
487
+ cli_warn(
488
+ "Found more facetting levels than designed. The following levels "
489
+ "are dropped: " + ", ".join(dropped)
490
+ )
491
+
492
+ # R: lnames <- attr(layout, "design_names"). The design_names attribute
493
+ # is set on `design` (validate_design), NOT on `layout`, so in R this is
494
+ # always NULL and the partial-match warning + reorder below is dead code.
495
+ # Mirror R exactly by reading it from `layout` (which carries no such
496
+ # attr) so the block never fires -- reading `design.names` here produced
497
+ # a spurious "partial match" warning that R never emits.
498
+ lnames = getattr(layout, "attrs", {}).get("design_names")
499
+ if lnames is not None and len(base.columns) > 0:
500
+ first_col = base.iloc[:, 0]
501
+ isect = [v for v in lnames if v in set(first_col)]
502
+ if len(isect) != 0 and len(isect) != len(base):
503
+ cli_warn(
504
+ "Only partial match found between facetting levels and design levels."
505
+ )
506
+ elif len(isect) > 0:
507
+ # base <- base[match(base[[1]], isect), ]
508
+ order_map = {v: i for i, v in enumerate(isect)}
509
+ new_order = [order_map.get(v, len(isect)) for v in first_col]
510
+ base = base.iloc[np.argsort(np.argsort(new_order, kind="mergesort"))].reset_index(drop=True)
511
+
512
+ if n < len(layout):
513
+ panel_int = np.asarray(layout["PANEL"].astype(int))
514
+ keep = panel_int <= n
515
+ layout = layout.loc[keep].reset_index(drop=True)
516
+ kept_ids = [p for p in panel_ids if p <= n]
517
+ layout["PANEL"] = pd.Categorical(
518
+ layout["PANEL"].astype(int), categories=kept_ids
519
+ )
520
+
521
+ id_int = np.asarray(id_arr, dtype=int)
522
+ order = np.argsort(id_int, kind="mergesort") # base[order(id), ]
523
+ base_ordered = base.iloc[order].reset_index(drop=True)
524
+
525
+ panels = pd.concat(
526
+ [layout.reset_index(drop=True), base_ordered], axis=1
527
+ )
528
+ panels["SCALE_X"] = np.arange(1, n + 1) if params["free"]["x"] else 1
529
+ panels["SCALE_Y"] = np.arange(1, n + 1) if params["free"]["y"] else 1
530
+
531
+ # order(PANEL)
532
+ panel_int = np.asarray(panels["PANEL"].astype(int))
533
+ panels = panels.iloc[np.argsort(panel_int, kind="mergesort")].reset_index(drop=True)
534
+ return panels
535
+
536
+ # -- map_data (R:203-240) -----------------------------------------------
537
+ def map_data(self, data: pd.DataFrame, layout: pd.DataFrame, params: Dict[str, Any]) -> pd.DataFrame:
538
+ """Assign ``PANEL`` to layer data by matching facet values to the layout.
539
+
540
+ Port of ggh4x's ``FacetManual$map_data`` (``R/facet_manual.R:203-240``).
541
+
542
+ Parameters
543
+ ----------
544
+ data : pandas.DataFrame
545
+ layout : pandas.DataFrame
546
+ params : dict
547
+
548
+ Returns
549
+ -------
550
+ pandas.DataFrame
551
+ *data* with a ``PANEL`` column; rows not matching any panel dropped.
552
+ """
553
+ if empty(data):
554
+ out = data.copy() if isinstance(data, pd.DataFrame) else pd.DataFrame()
555
+ out["PANEL"] = pd.Categorical([])
556
+ return out
557
+
558
+ vars_ = _resolve_facet_vars(params.get("facets"))
559
+ if len(vars_) == 0:
560
+ data = data.copy()
561
+ data["PANEL"] = list(layout["PANEL"])
562
+ return data
563
+
564
+ data = data.copy()
565
+ facet_vals = data[[v for v in vars_ if v in data.columns]].copy()
566
+ # Coerce facet vals + layout keys to factor (string) for matching.
567
+ for c in facet_vals.columns:
568
+ facet_vals[c] = facet_vals[c].astype(str)
569
+ layout = layout.copy()
570
+ lkeys = [v for v in vars_ if v in layout.columns]
571
+
572
+ missing_facets = [v for v in vars_ if v not in facet_vals.columns]
573
+ if missing_facets:
574
+ to_add = layout[missing_facets].drop_duplicates().reset_index(drop=True)
575
+ data_rep = np.repeat(np.arange(len(data)), len(to_add))
576
+ facet_rep = np.tile(np.arange(len(to_add)), len(data))
577
+ data = data.iloc[data_rep].reset_index(drop=True)
578
+ facet_vals = pd.concat(
579
+ [
580
+ facet_vals.iloc[data_rep].reset_index(drop=True),
581
+ to_add.iloc[facet_rep].reset_index(drop=True),
582
+ ],
583
+ axis=1,
584
+ )
585
+
586
+ # join_keys: match facet_vals to layout on the faceting vars.
587
+ layout_keys = layout[lkeys].copy()
588
+ for c in layout_keys.columns:
589
+ layout_keys[c] = layout_keys[c].astype(str)
590
+ for c in facet_vals.columns:
591
+ facet_vals[c] = facet_vals[c].astype(str)
592
+
593
+ layout_keys = layout_keys.assign(_PANEL=list(layout["PANEL"]))
594
+ merged = facet_vals.merge(layout_keys, on=lkeys, how="left")
595
+ data["PANEL"] = pd.Categorical(
596
+ merged["_PANEL"].to_numpy(),
597
+ categories=list(layout["PANEL"].cat.categories)
598
+ if isinstance(layout["PANEL"].dtype, pd.CategoricalDtype)
599
+ else None,
600
+ )
601
+ data = data.loc[~data["PANEL"].isna()].reset_index(drop=True)
602
+ return data
603
+
604
+ # -- setup_aspect_ratio (R:242-252) -------------------------------------
605
+ def setup_aspect_ratio(
606
+ self,
607
+ coord: Any,
608
+ free: Dict[str, bool],
609
+ theme: Any,
610
+ ranges: Sequence[Any],
611
+ ) -> AspectRatio:
612
+ """Resolve the aspect ratio + ``respect`` flag.
613
+
614
+ Port of ggh4x's ``FacetManual$setup_aspect_ratio``
615
+ (``R/facet_manual.R:242-252``). Unlike :class:`FacetWrap2`, R returns
616
+ ``NULL`` (not 1) in the free-scales / no-theme-aspect case, relying on the
617
+ inherited ``setup_panel_table`` to apply ``respect <- params$respect %||%
618
+ attr(aspect, "respect") %||% FALSE`` and ``heights <- params$heights %||%
619
+ unit(abs(aspect %||% 1), "null")``.
620
+
621
+ Because the Python :class:`FacetWrap2` ``setup_panel_table`` consumes an
622
+ :class:`AspectRatio` carrier (it reads ``aspect.value`` / ``aspect.respect``
623
+ and cannot accept ``None``), the ``NULL`` case is modelled here as
624
+ ``AspectRatio(1.0, False)`` -- exactly R's ``aspect %||% 1`` (heights fall
625
+ back to ``unit(1, "null")``) and ``attr(NULL, "respect") %||% FALSE``
626
+ (``respect`` defers to ``params['respect']``, which ``facet_manual``
627
+ always sets to a concrete bool). The non-``NULL`` case carries
628
+ ``respect=True`` like ``FacetWrap2``.
629
+
630
+ Parameters
631
+ ----------
632
+ coord : Coord
633
+ free : dict
634
+ theme : Theme
635
+ ranges : sequence
636
+
637
+ Returns
638
+ -------
639
+ AspectRatio
640
+ ``AspectRatio(1.0, False)`` for the R ``NULL`` case; otherwise the
641
+ theme/coord aspect with ``respect=True``.
642
+ """
643
+ aspect_ratio = calc_element("aspect.ratio", theme) if theme is not None else None
644
+ if aspect_ratio is None and not free["x"] and not free["y"] and ranges:
645
+ aspect_ratio = coord.aspect(ranges[0])
646
+ if aspect_ratio is None:
647
+ # R: returns NULL -> setup_panel_table uses `abs(aspect %||% 1)` = 1
648
+ # and `params$respect %||% attr(NULL,"respect") %||% FALSE`.
649
+ return AspectRatio(1.0, False)
650
+ return AspectRatio(float(aspect_ratio), True)
651
+
652
+ # -- setup_axes (R:254-294) ---------------------------------------------
653
+ def setup_axes(
654
+ self,
655
+ axes: Dict[str, Any],
656
+ layout: pd.DataFrame,
657
+ params: Dict[str, Any],
658
+ theme: Any,
659
+ ) -> pd.DataFrame:
660
+ """Pick per-panel axis grobs and decide span-aware purging.
661
+
662
+ Port of ggh4x's ``FacetManual$setup_axes`` (``R/facet_manual.R:254-294``).
663
+ Returns a DataFrame of per-panel axis grobs keyed by ``t``/``b``/``l``/``r``
664
+ = PANEL index (NOT the matrix + measurements list of :class:`FacetWrap2`).
665
+
666
+ Parameters
667
+ ----------
668
+ axes : dict
669
+ Transposed batch axes from :func:`render_axes`.
670
+ layout : pandas.DataFrame
671
+ params : dict
672
+ theme : Theme
673
+
674
+ Returns
675
+ -------
676
+ pandas.DataFrame
677
+ ``{t, b, l, r, axes_top, axes_bottom, axes_left, axes_right}``.
678
+ """
679
+ panel = [int(v) for v in layout["PANEL"].astype(int)]
680
+ scale_x = [int(v) for v in layout["SCALE_X"]]
681
+ scale_y = [int(v) for v in layout["SCALE_Y"]]
682
+
683
+ x_top = axes["x"]["top"]
684
+ x_bottom = axes["x"]["bottom"]
685
+ y_left = axes["y"]["left"]
686
+ y_right = axes["y"]["right"]
687
+
688
+ top = [x_top[i - 1] for i in scale_x]
689
+ bottom = [x_bottom[i - 1] for i in scale_x]
690
+ left = [y_left[i - 1] for i in scale_y]
691
+ right = [y_right[i - 1] for i in scale_y]
692
+
693
+ dot_top = list(layout[".TOP"])
694
+ dot_bottom = list(layout[".BOTTOM"])
695
+ dot_left = list(layout[".LEFT"])
696
+ dot_right = list(layout[".RIGHT"])
697
+
698
+ purge_x = (not params["free"]["x"]) and (params["rmlab"]["x"] or not params["axes"]["x"])
699
+ purge_y = (not params["free"]["y"]) and (params["rmlab"]["y"] or not params["axes"]["y"])
700
+
701
+ purge_x = purge_x and _do_purge(dot_left, dot_right)
702
+ purge_y = purge_y and _do_purge(dot_top, dot_bottom)
703
+
704
+ if purge_x:
705
+ purger = purge_guide_labels if params["rmlab"]["x"] else null_grob()
706
+ top = _restrict_axes(top, dot_top, dot_left, min, purger)
707
+ bottom = _restrict_axes(bottom, dot_bottom, dot_left, max, purger)
708
+
709
+ if purge_y:
710
+ purger = purge_guide_labels if params["rmlab"]["y"] else null_grob()
711
+ left = _restrict_axes(left, dot_left, dot_top, min, purger)
712
+ right = _restrict_axes(right, dot_right, dot_top, max, purger)
713
+
714
+ return pd.DataFrame(
715
+ {
716
+ "t": panel,
717
+ "b": panel,
718
+ "l": panel,
719
+ "r": panel,
720
+ "axes_top": top,
721
+ "axes_bottom": bottom,
722
+ "axes_left": left,
723
+ "axes_right": right,
724
+ }
725
+ )
726
+
727
+ # -- attach_axes (R:296-327) --------------------------------------------
728
+ def attach_axes(
729
+ self,
730
+ panels: Any,
731
+ axes: pd.DataFrame,
732
+ sizes: Dict[str, Unit],
733
+ params: Dict[str, Any],
734
+ inside: bool = True,
735
+ ) -> Any:
736
+ """Weave the four per-panel axis sides into the panel gtable.
737
+
738
+ Port of ggh4x's ``FacetManual$attach_axes`` (``R/facet_manual.R:296-327``).
739
+ Zeroes interior strip-side gaps when scales are fixed and the spans are
740
+ disjoint, then weaves each axis side via :func:`weave_panel_rows` /
741
+ :func:`weave_panel_cols`.
742
+
743
+ Parameters
744
+ ----------
745
+ panels : Gtable
746
+ axes : pandas.DataFrame
747
+ The per-panel grob frame from :meth:`setup_axes`.
748
+ sizes : dict
749
+ ``{top, bottom, left, right}`` size unit vectors.
750
+ params : dict
751
+ inside : bool, default True
752
+ Whether ``strip.placement`` is ``"inside"``.
753
+
754
+ Returns
755
+ -------
756
+ Gtable
757
+ """
758
+ panel_layout = _panel_layout(panels)
759
+ strip_pos = params.get("strip.position", params.get("strip_position", "top"))
760
+
761
+ if (not params["free"]["y"]) and _do_purge(panel_layout["t"], panel_layout["b"], True):
762
+ if inside or strip_pos != "left":
763
+ # sizes$left[-1] <- 0 (drop first element)
764
+ _zero_unit_slice(sizes, "left", drop="first")
765
+ if inside or strip_pos != "right":
766
+ _zero_unit_slice(sizes, "right", drop="last")
767
+ if (not params["free"]["x"]) and _do_purge(panel_layout["l"], panel_layout["r"], True):
768
+ # NOTE: R has a missing brace here so only the first line is the
769
+ # conditional body; the second `if` always runs. Reproduce faithfully.
770
+ if inside or strip_pos != "bottom":
771
+ _zero_unit_slice(sizes, "bottom", drop="last")
772
+ if inside or strip_pos != "top":
773
+ _zero_unit_slice(sizes, "top", drop="first")
774
+
775
+ panels = weave_panel_rows(
776
+ panels, axes, -1, sizes["top"], "axis-t", 3, "off", "t", "axes_top"
777
+ )
778
+ panels = weave_panel_rows(
779
+ panels, axes, 0, sizes["bottom"], "axis-b", 3, "off", "b", "axes_bottom"
780
+ )
781
+ panels = weave_panel_cols(
782
+ panels, axes, -1, sizes["left"], "axis-l", 3, "off", "l", "axes_left"
783
+ )
784
+ panels = weave_panel_cols(
785
+ panels, axes, 0, sizes["right"], "axis-r", 3, "off", "r", "axes_right"
786
+ )
787
+ return panels
788
+
789
+ # -- draw_panels (R:329-376) --------------------------------------------
790
+ def draw_panels(
791
+ self,
792
+ panels: list,
793
+ layout: pd.DataFrame,
794
+ x_scales: list,
795
+ y_scales: list,
796
+ ranges: list,
797
+ coord: Any,
798
+ data: Any,
799
+ theme: Any,
800
+ params: Dict[str, Any],
801
+ ) -> Any:
802
+ """Assemble the manual panel gtable (full replacement of the base pipeline).
803
+
804
+ Port of ggh4x's ``FacetManual$draw_panels`` (``R/facet_manual.R:329-376``).
805
+
806
+ Parameters
807
+ ----------
808
+ panels : list
809
+ layout : pandas.DataFrame
810
+ x_scales, y_scales : list
811
+ ranges : list
812
+ coord : Coord
813
+ data : Any
814
+ theme : Theme
815
+ params : dict
816
+
817
+ Returns
818
+ -------
819
+ Gtable
820
+ """
821
+ if (params["free"]["x"] or params["free"]["y"]) and not coord.is_free():
822
+ cli_abort(f"`{snake_class(coord)}` doesn't support free scales.")
823
+
824
+ strip = self.strip
825
+
826
+ # Decorate per-layer grobs into one panel grob per PANEL.
827
+ from ggh4x.facet_grid2 import _decorate_panels
828
+
829
+ panel_grobs = _decorate_panels(panels, layout, ranges, coord, theme)
830
+
831
+ # Inherited FacetWrap2.setup_panel_table reads .TOP/.BOTTOM/.LEFT/.RIGHT.
832
+ panel_table = self.setup_panel_table(
833
+ panel_grobs, layout, theme, coord, ranges, params
834
+ )
835
+
836
+ axes = render_axes(ranges, ranges, coord, theme, transpose=True)
837
+ axes = self.setup_axes(axes, layout, params, theme)
838
+
839
+ panel_pos = _panel_layout(panel_table)
840
+ sizes = {
841
+ "top": split_heights_cm(list(axes["axes_top"]), split=panel_pos["t"]),
842
+ "bottom": split_heights_cm(list(axes["axes_bottom"]), split=panel_pos["b"]),
843
+ "left": split_widths_cm(list(axes["axes_left"]), split=panel_pos["l"]),
844
+ "right": split_widths_cm(list(axes["axes_right"]), split=panel_pos["r"]),
845
+ }
846
+
847
+ strip_placement = calc_element("strip.placement", theme) if theme is not None else None
848
+ inside = (strip_placement if strip_placement is not None else "inside") == "inside"
849
+ panel_table = self.attach_axes(panel_table, axes, sizes, params, inside=inside)
850
+
851
+ # Synthesize ROW/COL late so Strip.setup(type="wrap") can treat the span
852
+ # layout like a wrap layout.
853
+ strip_pos = params.get("strip.position", params.get("strip_position", "top"))
854
+ simplify = {
855
+ "top": (".TOP", ".LEFT"),
856
+ "bottom": (".BOTTOM", ".LEFT"),
857
+ "left": (".TOP", ".LEFT"),
858
+ "right": (".TOP", ".RIGHT"),
859
+ }[strip_pos]
860
+ layout = layout.copy()
861
+ layout["ROW"] = layout[simplify[0]].to_numpy()
862
+ layout["COL"] = layout[simplify[1]].to_numpy()
863
+
864
+ strip.setup(layout, params, theme, type="wrap")
865
+ panel_table = strip.incorporate_wrap(
866
+ panel_table, strip_pos, clip=coord.clip, sizes=sizes
867
+ )
868
+
869
+ return self.finish_panels(
870
+ panels=panel_table, layout=layout, params=params, theme=theme
871
+ )
872
+
873
+
874
+ # ---------------------------------------------------------------------------
875
+ # Module-private helpers
876
+ # ---------------------------------------------------------------------------
877
+ def _panel_layout(table: Any) -> pd.DataFrame:
878
+ """Return the ``"panel-*"`` rows of *table*'s layout as a DataFrame (t/b/l/r)."""
879
+ lay = table.layout
880
+ if not isinstance(lay, pd.DataFrame):
881
+ lay = pd.DataFrame({k: list(v) for k, v in lay.items()})
882
+ mask = lay["name"].astype(str).str.match(r"^panel")
883
+ return lay.loc[mask, ["t", "b", "l", "r"]].reset_index(drop=True)
884
+
885
+
886
+ def _zero_unit_slice(sizes: Dict[str, Unit], key: str, drop: str) -> None:
887
+ """Zero all-but-one elements of ``sizes[key]`` in place (R negative-index drop).
888
+
889
+ ``drop="first"`` zeroes elements ``[1:]`` (R ``x[-1] <- 0``); ``drop="last"``
890
+ zeroes elements ``[:-1]`` (R ``x[-length(x)] <- 0``).
891
+ """
892
+ u = sizes[key]
893
+ n = len(u)
894
+ if n <= 1:
895
+ return
896
+ if drop == "first":
897
+ for i in range(1, n):
898
+ u[i] = Unit(0, "cm")
899
+ else: # last
900
+ for i in range(0, n - 1):
901
+ u[i] = Unit(0, "cm")