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
@@ -0,0 +1,647 @@
1
+ """Per-panel position scales (port of ggh4x ``R/facetted_pos_scales.R``).
2
+
3
+ :func:`facetted_pos_scales` returns a :class:`FacettedPosScales` add-on object.
4
+ When added to a plot, its handler clones the plot's live facet into a dynamic
5
+ ``FreeScaled<FacetClass>`` subclass whose ``init_scales`` / ``train_scales`` /
6
+ ``finish_data`` are replaced with per-panel variants: each ``SCALE_X`` /
7
+ ``SCALE_Y`` id gets its own cloned scale, user scales (with ``oob`` forced to
8
+ :func:`scales.oob_keep`) substituted at matched panels, and layer data
9
+ transformed per panel before training.
10
+
11
+ NSE deviation
12
+ -------------
13
+ R accepts a list of two-sided formulas whose LHS is tidy-evaluated against the
14
+ plot layout. Python has no NSE: instead an element may be a *position scale*, a
15
+ ``None``, or a ``(predicate, scale)`` pair where ``predicate`` is either a
16
+ callable ``layout_df -> bool-array`` or a string evaluated with
17
+ :meth:`pandas.DataFrame.eval` over the layout columns. The predicate list is
18
+ stored parallel to the scale list (R smuggles it via ``attr(., "lhs")``).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Any, Dict, List, Optional, Sequence, Union
24
+
25
+ import numpy as np
26
+ import pandas as pd
27
+ from scales import oob_keep
28
+
29
+ from ggplot2_py import ggproto
30
+ from ggplot2_py.facet import Facet
31
+ from ggplot2_py.ggproto import ggproto_parent
32
+ from ggplot2_py.plot import update_ggplot
33
+
34
+ from ggh4x._cli import cli_abort, cli_warn
35
+
36
+ __all__ = [
37
+ "facetted_pos_scales",
38
+ "FacettedPosScales",
39
+ "check_facetted_scale",
40
+ "validate_facetted_scale",
41
+ "init_scale",
42
+ "init_scales_individual",
43
+ "train_scales_individual",
44
+ "finish_data_individual",
45
+ "should_transform",
46
+ ]
47
+
48
+ # Facet class-name prefixes recognised as "known" (allowlist replacing R's
49
+ # body-identity comparison of init/train/finish; see panel_scales.md risk 8).
50
+ _KNOWN_FACET_PREFIXES = (
51
+ "FacetGrid",
52
+ "FacetWrap",
53
+ "FacetNull",
54
+ "FacetManual",
55
+ "FreeScaled",
56
+ "Forced",
57
+ )
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Predicate evaluation (NSE replacement)
62
+ # ---------------------------------------------------------------------------
63
+ def _eval_predicate(pred: Any, layout: pd.DataFrame) -> np.ndarray:
64
+ """Evaluate a panel predicate against the layout, returning a bool array.
65
+
66
+ The predicate may be a callable ``layout -> array-like`` or a string
67
+ expression evaluated with :meth:`pandas.DataFrame.eval`. This stands in for
68
+ R's tidy-evaluation of a formula LHS against the layout (``eval_tidy``).
69
+
70
+ Parameters
71
+ ----------
72
+ pred : callable or str
73
+ The panel predicate.
74
+ layout : pandas.DataFrame
75
+ The plot layout (columns ``PANEL`` / ``ROW`` / ``COL`` / ``SCALE_*`` +
76
+ facet variables).
77
+
78
+ Returns
79
+ -------
80
+ numpy.ndarray
81
+ A boolean array, recycled to ``len(layout)``.
82
+ """
83
+ if callable(pred):
84
+ res = pred(layout)
85
+ elif isinstance(pred, str):
86
+ res = layout.eval(pred, engine="python")
87
+ else:
88
+ res = pred
89
+ arr = np.asarray(res)
90
+ if arr.dtype != bool:
91
+ arr = arr.astype(bool)
92
+ n = len(layout)
93
+ if arr.ndim == 0:
94
+ arr = np.repeat(arr, n)
95
+ if len(arr) != n:
96
+ # rep_len recycling
97
+ arr = np.resize(arr, n)
98
+ return arr
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # ScaleList: a list carrying a parallel ``lhs`` predicate list (R attr "lhs")
103
+ # ---------------------------------------------------------------------------
104
+ class _ScaleList(list):
105
+ """A ``list`` of scales carrying an optional parallel ``lhs`` predicate list.
106
+
107
+ Stands in for R's ``structure(rhs, lhs = lhs, class = "list")``: a plain
108
+ list of scales (or ``None`` s) where ``self.lhs`` -- when not ``None`` --
109
+ holds one predicate per element (the formula LHS equivalent).
110
+ """
111
+
112
+ lhs: Optional[List[Any]] = None
113
+
114
+ def __init__(self, iterable: Sequence[Any] = (), lhs: Optional[List[Any]] = None) -> None:
115
+ super().__init__(iterable)
116
+ self.lhs = lhs
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # check_facetted_scale (R facetted_pos_scales.R:115-145)
121
+ # ---------------------------------------------------------------------------
122
+ def _is_scale(x: Any) -> bool:
123
+ """Return whether *x* looks like a ggplot2 Scale (has ``aesthetics``)."""
124
+ return x is not None and hasattr(x, "aesthetics") and hasattr(x, "clone")
125
+
126
+
127
+ def _is_formula_pair(x: Any) -> bool:
128
+ """Return whether *x* is a ``(predicate, scale)`` pair (formula equivalent)."""
129
+ return (
130
+ isinstance(x, (tuple, list))
131
+ and len(x) == 2
132
+ and (callable(x[0]) or isinstance(x[0], str))
133
+ and _is_scale(x[1])
134
+ )
135
+
136
+
137
+ def check_facetted_scale(x: Optional[Sequence[Any]], aes: str = "x", allow_null: bool = True) -> bool:
138
+ """Validate that *x* is a list of position scales / ``None`` s / formula pairs.
139
+
140
+ Faithful port of ggh4x's ``check_facetted_scale``
141
+ (``R/facetted_pos_scales.R:115-145``). Each element must be a position
142
+ :class:`~ggplot2_py.scale.Scale` carrying the *aes* aesthetic, a ``None``
143
+ (when ``allow_null``), or -- if *all* elements are formula pairs -- a
144
+ ``(predicate, scale)`` pair.
145
+
146
+ Parameters
147
+ ----------
148
+ x : sequence or None
149
+ The candidate scale list.
150
+ aes : {"x", "y"}, default "x"
151
+ Required aesthetic.
152
+ allow_null : bool, default True
153
+ Whether ``None`` elements are permitted.
154
+
155
+ Returns
156
+ -------
157
+ bool
158
+ ``True`` when *x* is a valid facetted-scale list.
159
+ """
160
+ if x is None:
161
+ return True
162
+
163
+ is_scale = [_is_scale(e) for e in x]
164
+ is_null = [e is None for e in x]
165
+ is_form = [_is_formula_pair(e) for e in x]
166
+
167
+ if x and all(is_form):
168
+ return True
169
+
170
+ # Scales must carry the right aesthetic.
171
+ appropriate = [
172
+ (aes in list(e.aesthetics)) for e, s in zip(x, is_scale) if s
173
+ ]
174
+ # is_scale[is_scale] <- is_scale[is_scale] & appropriate_aes
175
+ ai = 0
176
+ for i, s in enumerate(is_scale):
177
+ if s:
178
+ is_scale[i] = s and appropriate[ai]
179
+ ai += 1
180
+
181
+ if allow_null:
182
+ if all(s or n for s, n in zip(is_scale, is_null)):
183
+ return True
184
+ else:
185
+ if all(is_scale):
186
+ return True
187
+ return False
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # validate_facetted_scale (R facetted_pos_scales.R:149-177)
192
+ # ---------------------------------------------------------------------------
193
+ def validate_facetted_scale(x: Sequence[Any], aes: str = "x") -> _ScaleList:
194
+ """Split formula pairs into a scale list + parallel predicate (``lhs``) list.
195
+
196
+ Faithful port of ggh4x's ``validate_facetted_scale``
197
+ (``R/facetted_pos_scales.R:149-177``). When *x*'s first element is not a
198
+ formula pair the list is returned as-is. Otherwise each ``(predicate,
199
+ scale)`` pair is split: the predicate ``lhs`` is kept for later layout
200
+ evaluation, the scale ``rhs`` is validated for the *aes* aesthetic.
201
+
202
+ Parameters
203
+ ----------
204
+ x : sequence
205
+ The candidate (possibly formula-pair) list.
206
+ aes : {"x", "y"}, default "x"
207
+ Required aesthetic.
208
+
209
+ Returns
210
+ -------
211
+ _ScaleList
212
+ A scale list, with ``.lhs`` set to the parallel predicate list when *x*
213
+ was formula-based (else ``.lhs is None``).
214
+
215
+ Raises
216
+ ------
217
+ ValueError
218
+ When a formula pair's right-hand side is not an appropriate scale.
219
+ """
220
+ if not x or not _is_formula_pair(x[0]):
221
+ return _ScaleList(x, lhs=None)
222
+
223
+ lhs = [f[0] for f in x]
224
+ rhs = [f[1] for f in x]
225
+
226
+ if not check_facetted_scale(rhs, aes=aes, allow_null=False):
227
+ cli_abort(
228
+ "The right-hand side of formula does not result in an appropriate scale."
229
+ )
230
+ return _ScaleList(rhs, lhs=lhs)
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # facetted_pos_scales constructor (R facetted_pos_scales.R:79-112)
235
+ # ---------------------------------------------------------------------------
236
+ class FacettedPosScales:
237
+ """Deferred container of per-panel x / y position scales.
238
+
239
+ Port of R's ``structure(list(x =, y =), class = "facetted_pos_scales")``.
240
+ Consumed by :func:`_update_facetted_pos_scales` at ``+``-time.
241
+
242
+ Attributes
243
+ ----------
244
+ x, y : _ScaleList
245
+ Per-panel x / y scale lists (each possibly carrying a ``.lhs`` predicate
246
+ list).
247
+ """
248
+
249
+ def __init__(self, x: _ScaleList, y: _ScaleList) -> None:
250
+ self.x = x
251
+ self.y = y
252
+
253
+
254
+ def facetted_pos_scales(
255
+ x: Optional[Union[Sequence[Any], Any]] = None,
256
+ y: Optional[Union[Sequence[Any], Any]] = None,
257
+ ) -> FacettedPosScales:
258
+ """Set individual position scales in facets.
259
+
260
+ Faithful port of ggh4x's ``facetted_pos_scales``
261
+ (``R/facetted_pos_scales.R:79-112``). ``x`` / ``y`` are lists whose elements
262
+ are position scales, ``None`` s (use the default scale at that position), or
263
+ ``(predicate, scale)`` pairs targeting panels by predicate. The facet must
264
+ use free scales in the relevant direction.
265
+
266
+ Parameters
267
+ ----------
268
+ x, y : list or None, default None
269
+ Per-panel x / y position scales (or a single element, auto-wrapped).
270
+
271
+ Returns
272
+ -------
273
+ FacettedPosScales
274
+ An add-on object that can be added to a plot with ``+``.
275
+
276
+ Raises
277
+ ------
278
+ ValueError
279
+ When ``x`` or ``y`` is not a valid facetted-scale list.
280
+ """
281
+ if not isinstance(x, list):
282
+ x = [x]
283
+ if not isinstance(y, list):
284
+ y = [y]
285
+
286
+ x_test = check_facetted_scale(x, "x")
287
+ y_test = check_facetted_scale(y, "y")
288
+ if not (x_test and y_test):
289
+ if not x_test and not y_test:
290
+ arg, typ = "The `x` and `y` arguments ", "appropriate"
291
+ elif not x_test:
292
+ arg, typ = "The `x` argument ", "x"
293
+ else:
294
+ arg, typ = "The `y` argument ", "y"
295
+ cli_abort(
296
+ arg
297
+ + "should be `None`, or a list of formulas and/or position scales "
298
+ + f"with the {typ} aesthetic."
299
+ )
300
+
301
+ x = validate_facetted_scale(x, "x")
302
+ y = validate_facetted_scale(y, "y")
303
+ return FacettedPosScales(x=x, y=y)
304
+
305
+
306
+ # ---------------------------------------------------------------------------
307
+ # ggproto methods: init_scale / init_scales_individual (R:255-319)
308
+ # ---------------------------------------------------------------------------
309
+ def init_scale(
310
+ old: Any,
311
+ new: Optional[Sequence[Any]],
312
+ layout: pd.DataFrame,
313
+ aes: str = "x",
314
+ ) -> Optional[List[Any]]:
315
+ """Build the per-panel scale list for one aesthetic.
316
+
317
+ Faithful port of ggh4x's ``init_scale`` (``R/facetted_pos_scales.R:255-305``).
318
+ Clones the default *old* scale once per ``SCALE_<AES>`` id, then substitutes
319
+ user scales (with ``oob`` forced to :func:`scales.oob_keep`). Without
320
+ predicates, substitution is by list position; with predicates, panels are
321
+ matched by evaluating each predicate against *layout* and substitution
322
+ proceeds in reverse order so earlier-added scales win.
323
+
324
+ Parameters
325
+ ----------
326
+ old : Scale or None
327
+ The default prototype scale (``None`` -> returns ``None``).
328
+ new : sequence or _ScaleList or None
329
+ The user scale list (possibly carrying a ``.lhs`` predicate list).
330
+ layout : pandas.DataFrame
331
+ The plot layout.
332
+ aes : {"x", "y"}, default "x"
333
+ The aesthetic.
334
+
335
+ Returns
336
+ -------
337
+ list or None
338
+ One scale per ``SCALE_<AES>`` id, or ``None`` when *old* is ``None``.
339
+ """
340
+ if old is None:
341
+ return None
342
+
343
+ scalename = "SCALE_" + aes.upper()
344
+ n_ids = int(layout[scalename].max())
345
+ out: List[Any] = [old.clone() for _ in range(n_ids)]
346
+
347
+ lhs = getattr(new, "lhs", None)
348
+ if lhs is None:
349
+ # Regular: substitute at positions with a non-empty user scale.
350
+ for i, sc in enumerate(new or []):
351
+ if sc is None:
352
+ continue
353
+ clone = sc.clone()
354
+ clone.oob = oob_keep
355
+ if i < len(out):
356
+ out[i] = clone
357
+ else:
358
+ n = len(layout)
359
+ # Evaluate each predicate -> column of a logical matrix.
360
+ cols = [_eval_predicate(p, layout) for p in lhs]
361
+ for i in reversed(range(len(cols))):
362
+ mask = cols[i]
363
+ matched_rows = np.where(mask)[0]
364
+ # unique SCALE ids among matched layout rows
365
+ scale_ids = pd.unique(layout.iloc[matched_rows][scalename])
366
+ for sid in scale_ids:
367
+ clone = new[i].clone()
368
+ clone.oob = oob_keep
369
+ out[int(sid) - 1] = clone
370
+ return out
371
+
372
+
373
+ def init_scales_individual(
374
+ self: Any,
375
+ layout: pd.DataFrame,
376
+ x_scale: Any = None,
377
+ y_scale: Any = None,
378
+ params: Optional[Dict[str, Any]] = None,
379
+ ) -> Dict[str, list]:
380
+ """Per-panel ``init_scales`` (R ``init_scales_individual``).
381
+
382
+ Faithful port of ggh4x's ``init_scales_individual``
383
+ (``R/facetted_pos_scales.R:308-319``). Because ggplot2_py's layout calls
384
+ ``init_scales`` twice (x-only, then y-only), each aesthetic is guarded on
385
+ ``is not None`` so only the populated key is returned.
386
+
387
+ Parameters
388
+ ----------
389
+ self : Facet
390
+ The ``FreeScaled<...>`` facet instance (carries ``new_x_scales`` /
391
+ ``new_y_scales``).
392
+ layout : pandas.DataFrame
393
+ x_scale, y_scale : Scale or None
394
+ Prototype position scales (one provided per call).
395
+ params : dict, optional
396
+
397
+ Returns
398
+ -------
399
+ dict
400
+ ``{"x": [...]}`` or ``{"y": [...]}`` -- only the populated aesthetic.
401
+ """
402
+ scales: Dict[str, list] = {}
403
+ if x_scale is not None:
404
+ res = init_scale(x_scale, self.new_x_scales, layout, aes="x")
405
+ if res is not None:
406
+ scales["x"] = res
407
+ if y_scale is not None:
408
+ res = init_scale(y_scale, self.new_y_scales, layout, aes="y")
409
+ if res is not None:
410
+ scales["y"] = res
411
+ return scales
412
+
413
+
414
+ def train_scales_individual(
415
+ self: Any,
416
+ x_scales: list,
417
+ y_scales: list,
418
+ layout: pd.DataFrame,
419
+ data: List[pd.DataFrame],
420
+ params: Optional[Dict[str, Any]] = None,
421
+ ) -> None:
422
+ """Per-panel ``train_scales`` (R ``train_scales_individual``).
423
+
424
+ Faithful port of ggh4x's ``train_scales_individual``
425
+ (``R/facetted_pos_scales.R:322-332``). Transforms each layer's data through
426
+ :func:`finish_data_individual` *first* (so per-panel transforms precede
427
+ training), then delegates to the parent :class:`~ggplot2_py.facet.Facet`'s
428
+ ``train_scales``.
429
+
430
+ Parameters
431
+ ----------
432
+ self : Facet
433
+ x_scales, y_scales : list
434
+ layout : pandas.DataFrame
435
+ data : list of DataFrame
436
+ params : dict, optional
437
+ """
438
+ data = [
439
+ self.finish_data(ld, layout, x_scales, y_scales, params)
440
+ for ld in data
441
+ ]
442
+ ggproto_parent(Facet, self).train_scales(
443
+ x_scales, y_scales, layout, data, params
444
+ )
445
+
446
+
447
+ def finish_data_individual(
448
+ self: Any,
449
+ data: pd.DataFrame,
450
+ layout: pd.DataFrame,
451
+ x_scales: list,
452
+ y_scales: list,
453
+ params: Optional[Dict[str, Any]] = None,
454
+ ) -> pd.DataFrame:
455
+ """Per-panel ``finish_data`` (R ``finish_data_individual``).
456
+
457
+ Faithful port of ggh4x's ``finish_data_individual``
458
+ (``R/facetted_pos_scales.R:335-368``). Splits *data* by ``PANEL`` (keeping
459
+ the exact input row positions), matches each chunk to its ``SCALE_X`` /
460
+ ``SCALE_Y`` ids, transforms the appropriate columns through the panel's
461
+ scales, and recombines preserving the original row order.
462
+
463
+ Parameters
464
+ ----------
465
+ self : Facet
466
+ Carries ``new_x_scales`` / ``new_y_scales``.
467
+ data : pandas.DataFrame
468
+ layout : pandas.DataFrame
469
+ x_scales, y_scales : list
470
+ params : dict, optional
471
+
472
+ Returns
473
+ -------
474
+ pandas.DataFrame
475
+ *data* with per-panel-transformed position columns, original order.
476
+ """
477
+ if data is None or len(data) == 0 or "PANEL" not in data.columns:
478
+ return data
479
+
480
+ regular_x = _scalelist_len(self.new_x_scales) == 0
481
+ regular_y = _scalelist_len(self.new_y_scales) == 0
482
+
483
+ # Split by PANEL preserving positional indices.
484
+ groups = data.groupby("PANEL", observed=True).indices # PANEL -> int positions
485
+
486
+ panel_codes = layout["PANEL"]
487
+ # Numeric codes of layout PANEL for matching (R: match(as.numeric(...), layout$PANEL)).
488
+ if isinstance(panel_codes.dtype, pd.CategoricalDtype):
489
+ layout_panel_num = panel_codes.cat.codes.to_numpy() + 1
490
+ else:
491
+ layout_panel_num = pd.to_numeric(panel_codes, errors="coerce").to_numpy()
492
+
493
+ out = data.copy()
494
+
495
+ for panel_val, idx in groups.items():
496
+ if len(idx) == 0:
497
+ continue
498
+ # numeric code of this panel
499
+ if isinstance(data["PANEL"].dtype, pd.CategoricalDtype):
500
+ cats = list(data["PANEL"].cat.categories)
501
+ panel_num = cats.index(panel_val) + 1 if panel_val in cats else None
502
+ try:
503
+ panel_num = int(panel_val)
504
+ except (TypeError, ValueError):
505
+ panel_num = cats.index(panel_val) + 1 if panel_val in cats else None
506
+ else:
507
+ panel_num = int(panel_val)
508
+
509
+ matches = np.where(layout_panel_num == panel_num)[0]
510
+ if len(matches) == 0:
511
+ continue
512
+ panel_id = matches[0]
513
+ xidx = int(layout.iloc[panel_id]["SCALE_X"]) - 1
514
+ yidx = int(layout.iloc[panel_id]["SCALE_Y"]) - 1
515
+
516
+ chunk = data.iloc[idx]
517
+ y_vars = should_transform(
518
+ y_scales[yidx] if 0 <= yidx < len(y_scales) else None,
519
+ list(chunk.columns),
520
+ )
521
+ x_vars = should_transform(
522
+ x_scales[xidx] if 0 <= xidx < len(x_scales) else None,
523
+ list(chunk.columns),
524
+ )
525
+ if regular_x:
526
+ x_vars = []
527
+ if regular_y:
528
+ y_vars = []
529
+
530
+ for j in y_vars:
531
+ out.iloc[idx, out.columns.get_loc(j)] = y_scales[yidx].transform(
532
+ chunk[j].to_numpy()
533
+ )
534
+ for j in x_vars:
535
+ out.iloc[idx, out.columns.get_loc(j)] = x_scales[xidx].transform(
536
+ chunk[j].to_numpy()
537
+ )
538
+ return out
539
+
540
+
541
+ def _scalelist_len(scales: Optional[Sequence[Any]]) -> int:
542
+ """Return ``sum(lengths(scales))``: count of non-``None`` scale elements."""
543
+ if scales is None:
544
+ return 0
545
+ return sum(1 for s in scales if s is not None)
546
+
547
+
548
+ def should_transform(scale: Any, columns: Sequence[str]) -> List[str]:
549
+ """Return the columns to transform for a panel's scale.
550
+
551
+ Faithful port of ggh4x's ``should_transform``
552
+ (``R/facetted_pos_scales.R:370-378``): no columns for a ``None`` scale, a
553
+ discrete scale, or a date/time/hms transformation; otherwise the
554
+ intersection of the scale's aesthetics with *columns*.
555
+
556
+ Parameters
557
+ ----------
558
+ scale : Scale or None
559
+ columns : sequence of str
560
+
561
+ Returns
562
+ -------
563
+ list of str
564
+ """
565
+ if scale is None or scale.is_discrete():
566
+ return []
567
+ trans = _get_transformation(scale)
568
+ name = getattr(trans, "name", None)
569
+ if name in ("date", "time", "hms"):
570
+ return []
571
+ return [c for c in scale.aesthetics if c in columns]
572
+
573
+
574
+ def _get_transformation(scale: Any) -> Any:
575
+ """Return a scale's transformation object (ggh4x ``get_transformation``)."""
576
+ if hasattr(scale, "get_transformation"):
577
+ return scale.get_transformation()
578
+ return getattr(scale, "trans", None)
579
+
580
+
581
+ # ---------------------------------------------------------------------------
582
+ # ggplot_add.facetted_pos_scales (R facetted_pos_scales.R:186-250)
583
+ # ---------------------------------------------------------------------------
584
+ @update_ggplot.register(FacettedPosScales)
585
+ def _update_facetted_pos_scales(obj: FacettedPosScales, plot: Any, object_name: str = "") -> Any:
586
+ """Add a :class:`FacettedPosScales` to *plot* (R ``ggplot_add.facetted_pos_scales``).
587
+
588
+ Clones the plot's facet into a ``FreeScaled<FacetClass>`` whose
589
+ ``init_scales`` / ``train_scales`` / ``finish_data`` are the per-panel
590
+ variants; re-additions onto an already-``FreeScaled`` facet just update the
591
+ new-scale lists.
592
+
593
+ Parameters
594
+ ----------
595
+ obj : FacettedPosScales
596
+ plot : ggplot2_py.plot.GGPlot
597
+ object_name : str, optional
598
+
599
+ Returns
600
+ -------
601
+ ggplot2_py.plot.GGPlot
602
+ """
603
+ empty_x = [e is None for e in obj.x]
604
+ empty_y = [e is None for e in obj.y]
605
+ if all(empty_x) and all(empty_y):
606
+ return plot
607
+
608
+ facet = plot.facet
609
+ if type(facet).__name__.startswith("FreeScaled"):
610
+ # Already initialised; just update scale lists.
611
+ if not all(empty_x):
612
+ facet.new_x_scales = obj.x
613
+ if not all(empty_y):
614
+ facet.new_y_scales = obj.y
615
+ return plot
616
+
617
+ # Validity warning (allowlist replaces R body-identity check).
618
+ if not type(facet).__name__.startswith(_KNOWN_FACET_PREFIXES):
619
+ cli_warn(
620
+ f"Unknown facet: {type(facet).__name__}. "
621
+ "Overriding facetted scales may be unstable."
622
+ )
623
+
624
+ free = facet.params.get("free") if facet.params else None
625
+ if free is not None:
626
+ if free.get("x") is not None and sum(not e for e in empty_x) > 0 and not free["x"]:
627
+ cli_warn(
628
+ "Attempting to add facetted x scales, while x scales are not free. "
629
+ 'Try adding `scales = "free_x"` to the facet.'
630
+ )
631
+ if free.get("y") is not None and sum(not e for e in empty_y) > 0 and not free["y"]:
632
+ cli_warn(
633
+ "Attempting to add facetted y scales, while y scales are not free. "
634
+ 'Try adding `scales = "free_y"` to the facet.'
635
+ )
636
+
637
+ new_facet = ggproto(
638
+ f"FreeScaled{type(facet).__name__}",
639
+ facet,
640
+ new_x_scales=obj.x,
641
+ new_y_scales=obj.y,
642
+ init_scales=init_scales_individual,
643
+ train_scales=train_scales_individual,
644
+ finish_data=finish_data_individual,
645
+ )
646
+ plot.facet = new_facet
647
+ return plot