ggplot2-python 4.0.2.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 (54) hide show
  1. ggplot2_py/__init__.py +852 -0
  2. ggplot2_py/_compat.py +475 -0
  3. ggplot2_py/_plugins.py +129 -0
  4. ggplot2_py/_utils.py +544 -0
  5. ggplot2_py/aes.py +586 -0
  6. ggplot2_py/annotation.py +540 -0
  7. ggplot2_py/coord.py +2108 -0
  8. ggplot2_py/coords/__init__.py +49 -0
  9. ggplot2_py/datasets.py +265 -0
  10. ggplot2_py/draw_key.py +454 -0
  11. ggplot2_py/facet.py +1456 -0
  12. ggplot2_py/fortify.py +95 -0
  13. ggplot2_py/geom.py +4516 -0
  14. ggplot2_py/geoms/__init__.py +12 -0
  15. ggplot2_py/ggproto.py +279 -0
  16. ggplot2_py/guide.py +2925 -0
  17. ggplot2_py/guide_axis.py +615 -0
  18. ggplot2_py/guide_colourbar.py +657 -0
  19. ggplot2_py/guide_legend.py +1061 -0
  20. ggplot2_py/guides/__init__.py +8 -0
  21. ggplot2_py/labeller.py +296 -0
  22. ggplot2_py/labels.py +309 -0
  23. ggplot2_py/layer.py +954 -0
  24. ggplot2_py/layout.py +754 -0
  25. ggplot2_py/limits.py +314 -0
  26. ggplot2_py/plot.py +1401 -0
  27. ggplot2_py/plot_render.py +866 -0
  28. ggplot2_py/position.py +1269 -0
  29. ggplot2_py/protocols.py +171 -0
  30. ggplot2_py/py.typed +0 -0
  31. ggplot2_py/qplot.py +233 -0
  32. ggplot2_py/resources/diamonds.csv +53941 -0
  33. ggplot2_py/resources/economics.csv +575 -0
  34. ggplot2_py/resources/economics_long.csv +2871 -0
  35. ggplot2_py/resources/faithfuld.csv +5626 -0
  36. ggplot2_py/resources/luv_colours.csv +658 -0
  37. ggplot2_py/resources/midwest.csv +438 -0
  38. ggplot2_py/resources/mpg.csv +235 -0
  39. ggplot2_py/resources/msleep.csv +84 -0
  40. ggplot2_py/resources/presidential.csv +13 -0
  41. ggplot2_py/resources/seals.csv +1156 -0
  42. ggplot2_py/resources/txhousing.csv +8603 -0
  43. ggplot2_py/save.py +316 -0
  44. ggplot2_py/scale.py +2727 -0
  45. ggplot2_py/scales/__init__.py +4252 -0
  46. ggplot2_py/stat.py +6071 -0
  47. ggplot2_py/stats/__init__.py +9 -0
  48. ggplot2_py/theme.py +490 -0
  49. ggplot2_py/theme_defaults.py +1350 -0
  50. ggplot2_py/theme_elements.py +2052 -0
  51. ggplot2_python-4.0.2.9000.dist-info/METADATA +179 -0
  52. ggplot2_python-4.0.2.9000.dist-info/RECORD +54 -0
  53. ggplot2_python-4.0.2.9000.dist-info/WHEEL +4 -0
  54. ggplot2_python-4.0.2.9000.dist-info/licenses/LICENSE +3 -0
ggplot2_py/facet.py ADDED
@@ -0,0 +1,1456 @@
1
+ """
2
+ Faceting system for ggplot2.
3
+
4
+ Facets control how data is split into subsets and displayed as a matrix
5
+ of panels. The base :class:`Facet` class defines the interface; concrete
6
+ implementations include :class:`FacetNull` (no faceting),
7
+ :class:`FacetGrid` (rows x columns grid), and :class:`FacetWrap`
8
+ (1-d ribbon wrapped into 2-d).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
15
+
16
+ import numpy as np
17
+ import pandas as pd
18
+
19
+ from ggplot2_py._compat import Waiver, is_waiver, waiver, cli_abort, cli_warn
20
+ from ggplot2_py.ggproto import GGProto, ggproto
21
+ from ggplot2_py._utils import snake_class, compact, modify_list, empty
22
+
23
+
24
+ def _is_null_grob(grob: Any) -> bool:
25
+ """Check if a grob is a null grob (R semantics: zeroGrob / nullGrob)."""
26
+ if grob is None:
27
+ return True
28
+ cls = getattr(grob, "_grid_class", "")
29
+ name = getattr(grob, "_name", getattr(grob, "name", ""))
30
+ return cls == "null" or "null" in str(name).lower() or "zero" in str(name).lower()
31
+
32
+
33
+ def _axis_width_cm(ax: Any) -> float:
34
+ """Measure axis grob width in cm.
35
+
36
+ R measures axis width via ``gtable_width(gt)`` + ``convertUnit(..., "cm")``.
37
+ No fallback — if measurement fails, let it surface.
38
+ """
39
+ from gtable_py import Gtable, gtable_width
40
+ from grid_py import convert_width
41
+ if isinstance(ax, Gtable):
42
+ w = gtable_width(ax)
43
+ result = convert_width(w, "cm", valueOnly=True)
44
+ return float(np.sum(result))
45
+ # _AbsoluteAxisGrob path
46
+ val = getattr(ax, "_width_cm", None)
47
+ if val is not None:
48
+ return val
49
+ # width_details path
50
+ if hasattr(ax, "width_details"):
51
+ from ggplot2_py.guide_axis import _width_cm
52
+ return _width_cm(ax)
53
+ raise ValueError(f"Cannot measure width of {type(ax).__name__}")
54
+
55
+
56
+ def _axis_height_cm(ax: Any) -> float:
57
+ """Measure axis grob height in cm.
58
+
59
+ R measures axis height via ``gtable_height(gt)`` + ``convertUnit(..., "cm")``.
60
+ No fallback — if measurement fails, let it surface.
61
+ """
62
+ from gtable_py import Gtable, gtable_height
63
+ from grid_py import convert_height
64
+ if isinstance(ax, Gtable):
65
+ h = gtable_height(ax)
66
+ result = convert_height(h, "cm", valueOnly=True)
67
+ return float(np.sum(result))
68
+ val = getattr(ax, "_height_cm", None)
69
+ if val is not None:
70
+ return val
71
+ if hasattr(ax, "height_details"):
72
+ from ggplot2_py.guide_axis import _height_cm
73
+ return _height_cm(ax)
74
+ raise ValueError(f"Cannot measure height of {type(ax).__name__}")
75
+
76
+ __all__ = [
77
+ "Facet",
78
+ "FacetNull",
79
+ "FacetGrid",
80
+ "FacetWrap",
81
+ "facet_null",
82
+ "facet_grid",
83
+ "facet_wrap",
84
+ "is_facet",
85
+ ]
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Helpers
90
+ # ---------------------------------------------------------------------------
91
+
92
+ def _layout_null() -> pd.DataFrame:
93
+ """Return a single-panel layout.
94
+
95
+ Returns
96
+ -------
97
+ pd.DataFrame
98
+ One-row layout with columns ``PANEL``, ``ROW``, ``COL``,
99
+ ``SCALE_X``, ``SCALE_Y``.
100
+ """
101
+ return pd.DataFrame({
102
+ "PANEL": pd.Categorical([1]),
103
+ "ROW": [1],
104
+ "COL": [1],
105
+ "SCALE_X": [1],
106
+ "SCALE_Y": [1],
107
+ })
108
+
109
+
110
+ def _n2mfrow(n: int) -> Tuple[int, int]:
111
+ """Port of R's ``grDevices::n2mfrow`` (aspect=1).
112
+
113
+ Returns ``(rows, cols)``. R uses this to compute panel grid
114
+ shapes for ``par(mfrow=...)``; ``facet_wrap`` reuses it and
115
+ *swaps* the result so that ``nrow<-rc[2]; ncol<-rc[1]`` —
116
+ giving a wide-preferring layout (1×n for n ≤ 3).
117
+ """
118
+ if n <= 3:
119
+ return (n, 1)
120
+ if n <= 6:
121
+ return ((n + 1) // 2, 2)
122
+ if n <= 12:
123
+ return ((n + 2) // 3, 3)
124
+ asp = 1
125
+ return (math.ceil(math.sqrt(n / asp)), math.ceil(math.sqrt(n * asp)))
126
+
127
+
128
+ def _wrap_dims(n: int, nrow: Optional[int] = None, ncol: Optional[int] = None) -> Tuple[int, int]:
129
+ """Compute grid dimensions for *n* panels.
130
+
131
+ Mirrors R's ``wrap_dims()`` (facet-wrap.R:478-493): when both
132
+ nrow and ncol are ``NULL``, uses ``n2mfrow`` and swaps the axes
133
+ so n=3 → 1×3 (not 2×2).
134
+
135
+ Parameters
136
+ ----------
137
+ n : int
138
+ Number of panels.
139
+ nrow, ncol : int or None
140
+
141
+ Returns
142
+ -------
143
+ tuple of (nrow, ncol)
144
+
145
+ Raises
146
+ ------
147
+ ValueError
148
+ If the grid is too small for *n* panels.
149
+ """
150
+ if nrow is None and ncol is None:
151
+ # R: rc <- n2mfrow(n); nrow <- rc[2]; ncol <- rc[1]
152
+ rc = _n2mfrow(n)
153
+ nrow = rc[1]
154
+ ncol = rc[0]
155
+ elif ncol is None:
156
+ ncol = math.ceil(n / nrow)
157
+ elif nrow is None:
158
+ nrow = math.ceil(n / ncol)
159
+
160
+ if nrow * ncol < n:
161
+ cli_abort(
162
+ f"Need {n} panels, but nrow*ncol = {nrow * ncol}. "
163
+ "Increase nrow and/or ncol."
164
+ )
165
+ return nrow, ncol
166
+
167
+
168
+ def _resolve_facet_vars(facets: Any) -> List[str]:
169
+ """Resolve *facets* specification to a list of column-name strings.
170
+
171
+ Parameters
172
+ ----------
173
+ facets : str, list, tuple, or None
174
+ Faceting variable specification.
175
+
176
+ Returns
177
+ -------
178
+ list of str
179
+ """
180
+ if facets is None:
181
+ return []
182
+ if isinstance(facets, str):
183
+ # Could be formula-like "a + b" or simple name
184
+ parts = [s.strip() for s in facets.replace("~", " ").replace("+", " ").split()]
185
+ return [p for p in parts if p and p != "."]
186
+ if isinstance(facets, (list, tuple)):
187
+ result = []
188
+ for f in facets:
189
+ if isinstance(f, str):
190
+ result.append(f)
191
+ else:
192
+ result.append(str(f))
193
+ return result
194
+ if isinstance(facets, dict):
195
+ return list(facets.keys())
196
+ return []
197
+
198
+
199
+ def _combine_vars(
200
+ data_list: List[pd.DataFrame],
201
+ vars_: List[str],
202
+ drop: bool = True,
203
+ ) -> pd.DataFrame:
204
+ """Combine the unique values of *vars_* across all datasets.
205
+
206
+ Parameters
207
+ ----------
208
+ data_list : list of DataFrame
209
+ vars_ : list of str
210
+ drop : bool
211
+
212
+ Returns
213
+ -------
214
+ pd.DataFrame
215
+ Unique combinations of the faceting variables.
216
+ """
217
+ if not vars_:
218
+ return pd.DataFrame()
219
+
220
+ frames = []
221
+ for df in data_list:
222
+ if df is None or (isinstance(df, pd.DataFrame) and len(df) == 0):
223
+ continue
224
+ cols = [c for c in vars_ if c in df.columns]
225
+ if cols:
226
+ frames.append(df[cols].drop_duplicates())
227
+
228
+ if not frames:
229
+ return pd.DataFrame({v: pd.Series(dtype=object) for v in vars_})
230
+
231
+ combined = pd.concat(frames, ignore_index=True).drop_duplicates().reset_index(drop=True)
232
+ # Fill missing columns
233
+ for v in vars_:
234
+ if v not in combined.columns:
235
+ combined[v] = "(all)"
236
+ combined = combined[vars_].reset_index(drop=True)
237
+ # R (facet-.R: combine_vars calls df_layout which runs unique +
238
+ # sort via reorder/id on the faceting vars): for non-factor
239
+ # inputs, panel order follows ``sort(unique(x))``. Factor inputs
240
+ # keep level order. Mirrors the same alphabetical rule we fixed
241
+ # for discrete scales in scales_py/range.py.
242
+ sort_cols = [c for c in vars_
243
+ if c in combined.columns
244
+ and not hasattr(combined[c], "cat")]
245
+ if sort_cols:
246
+ try:
247
+ combined = combined.sort_values(sort_cols, kind="mergesort").reset_index(drop=True)
248
+ except TypeError:
249
+ # Mixed / unsortable types — fall back to insertion order
250
+ pass
251
+ return combined
252
+
253
+
254
+ def _map_facet_data(
255
+ data: pd.DataFrame,
256
+ layout: pd.DataFrame,
257
+ params: Dict[str, Any],
258
+ facet_vars: List[str],
259
+ ) -> pd.DataFrame:
260
+ """Map data rows to panels.
261
+
262
+ Parameters
263
+ ----------
264
+ data : pd.DataFrame
265
+ Layer data.
266
+ layout : pd.DataFrame
267
+ Layout with faceting variable columns and ``PANEL``.
268
+ params : dict
269
+ facet_vars : list of str
270
+
271
+ Returns
272
+ -------
273
+ pd.DataFrame
274
+ Data with a ``PANEL`` column.
275
+ """
276
+ if data is None or (isinstance(data, pd.DataFrame) and len(data) == 0):
277
+ return pd.DataFrame({"PANEL": pd.Categorical([])})
278
+
279
+ if is_waiver(data):
280
+ return pd.DataFrame({"PANEL": pd.Categorical([])})
281
+
282
+ data = data.copy()
283
+ if not facet_vars:
284
+ data["PANEL"] = pd.Categorical([1] * len(data))
285
+ return data
286
+
287
+ # Match data to layout on facet vars
288
+ present = [v for v in facet_vars if v in data.columns and v in layout.columns]
289
+ if not present:
290
+ # No matching vars: repeat across all panels
291
+ data["PANEL"] = pd.Categorical([1] * len(data))
292
+ return data
293
+
294
+ # Merge to get PANEL assignment
295
+ merged = data.merge(
296
+ layout[present + ["PANEL"]],
297
+ on=present,
298
+ how="left",
299
+ )
300
+ # Rows that didn't match any panel get dropped
301
+ merged = merged.dropna(subset=["PANEL"]).reset_index(drop=True)
302
+ merged["PANEL"] = pd.Categorical(merged["PANEL"])
303
+ return merged
304
+
305
+
306
+ # ---------------------------------------------------------------------------
307
+ # Base Facet
308
+ # ---------------------------------------------------------------------------
309
+
310
+ class Facet(GGProto):
311
+ """Base facet class.
312
+
313
+ Attributes
314
+ ----------
315
+ shrink : bool
316
+ Whether to shrink scales to fit stat output.
317
+ params : dict
318
+ Faceting parameters (populated by the constructor).
319
+ """
320
+
321
+ # --- Auto-registration registry (Python-exclusive) -------------------
322
+ _registry: Dict[str, Any] = {}
323
+
324
+ def __init_subclass__(cls, **kwargs: Any) -> None:
325
+ super().__init_subclass__(**kwargs)
326
+ name = cls.__name__
327
+ if name.startswith("Facet") and len(name) > 5:
328
+ key = name[5:]
329
+ Facet._registry[key] = cls
330
+ Facet._registry[key.lower()] = cls
331
+
332
+ shrink: bool = False
333
+ params: Dict[str, Any] = {}
334
+
335
+ def setup_params(
336
+ self,
337
+ data: List[pd.DataFrame],
338
+ params: Dict[str, Any],
339
+ ) -> Dict[str, Any]:
340
+ """Validate and modify faceting parameters.
341
+
342
+ Parameters
343
+ ----------
344
+ data : list of DataFrame
345
+ Global + layer data.
346
+ params : dict
347
+
348
+ Returns
349
+ -------
350
+ dict
351
+ """
352
+ all_cols: List[str] = []
353
+ for df in data:
354
+ if isinstance(df, pd.DataFrame):
355
+ all_cols.extend(df.columns.tolist())
356
+ params["_possible_columns"] = list(set(all_cols))
357
+ return params
358
+
359
+ def setup_data(
360
+ self, data: List[pd.DataFrame], params: Dict[str, Any]
361
+ ) -> List[pd.DataFrame]:
362
+ """Modify data before processing.
363
+
364
+ Parameters
365
+ ----------
366
+ data : list of DataFrame
367
+ params : dict
368
+
369
+ Returns
370
+ -------
371
+ list of DataFrame
372
+ """
373
+ return data
374
+
375
+ def compute_layout(
376
+ self,
377
+ data: List[pd.DataFrame],
378
+ params: Dict[str, Any],
379
+ ) -> pd.DataFrame:
380
+ """Create the panel layout table.
381
+
382
+ Parameters
383
+ ----------
384
+ data : list of DataFrame
385
+ params : dict
386
+
387
+ Returns
388
+ -------
389
+ pd.DataFrame
390
+ Must have ``PANEL``, ``ROW``, ``COL``, ``SCALE_X``, ``SCALE_Y``.
391
+
392
+ Raises
393
+ ------
394
+ NotImplementedError
395
+ In the base class.
396
+ """
397
+ cli_abort("compute_layout() is not implemented in the base Facet class.")
398
+
399
+ def map_data(
400
+ self,
401
+ data: pd.DataFrame,
402
+ layout: pd.DataFrame,
403
+ params: Dict[str, Any],
404
+ ) -> pd.DataFrame:
405
+ """Assign data rows to panels via the ``PANEL`` column.
406
+
407
+ Parameters
408
+ ----------
409
+ data : pd.DataFrame
410
+ layout : pd.DataFrame
411
+ params : dict
412
+
413
+ Returns
414
+ -------
415
+ pd.DataFrame
416
+ """
417
+ cli_abort("map_data() is not implemented in the base Facet class.")
418
+
419
+ def init_scales(
420
+ self,
421
+ layout: pd.DataFrame,
422
+ x_scale: Any = None,
423
+ y_scale: Any = None,
424
+ params: Optional[Dict[str, Any]] = None,
425
+ ) -> Dict[str, list]:
426
+ """Initialise per-panel scales.
427
+
428
+ Parameters
429
+ ----------
430
+ layout : pd.DataFrame
431
+ x_scale, y_scale : Scale or None
432
+ Prototype scales.
433
+ params : dict
434
+
435
+ Returns
436
+ -------
437
+ dict
438
+ ``{"x": [scales...], "y": [scales...]}``.
439
+ """
440
+ scales: Dict[str, list] = {}
441
+ if x_scale is not None:
442
+ n_x = int(layout["SCALE_X"].max())
443
+ scales["x"] = [x_scale] * n_x
444
+ if y_scale is not None:
445
+ n_y = int(layout["SCALE_Y"].max())
446
+ scales["y"] = [y_scale] * n_y
447
+ return scales
448
+
449
+ def train_scales(
450
+ self,
451
+ x_scales: list,
452
+ y_scales: list,
453
+ layout: pd.DataFrame,
454
+ data: List[pd.DataFrame],
455
+ params: Optional[Dict[str, Any]] = None,
456
+ ) -> None:
457
+ """Train per-panel scales on data.
458
+
459
+ Parameters
460
+ ----------
461
+ x_scales, y_scales : list
462
+ layout : pd.DataFrame
463
+ data : list of DataFrame
464
+ params : dict
465
+ """
466
+ for layer_data in data:
467
+ if layer_data is None or (hasattr(layer_data, "empty") and layer_data.empty):
468
+ continue
469
+ if "PANEL" not in layer_data.columns:
470
+ continue
471
+ for _, row in layout.iterrows():
472
+ panel_id = row["PANEL"]
473
+ sx_idx = int(row["SCALE_X"]) - 1
474
+ sy_idx = int(row["SCALE_Y"]) - 1
475
+ mask = layer_data["PANEL"] == panel_id
476
+ panel_data = layer_data.loc[mask]
477
+ if panel_data.empty:
478
+ continue
479
+ if x_scales and sx_idx < len(x_scales):
480
+ x_scales[sx_idx].train_df(panel_data)
481
+ if y_scales and sy_idx < len(y_scales):
482
+ y_scales[sy_idx].train_df(panel_data)
483
+
484
+ def finish_data(
485
+ self,
486
+ data: pd.DataFrame,
487
+ layout: pd.DataFrame,
488
+ x_scales: list,
489
+ y_scales: list,
490
+ params: Optional[Dict[str, Any]] = None,
491
+ ) -> pd.DataFrame:
492
+ """Final data adjustments.
493
+
494
+ Parameters
495
+ ----------
496
+ data : pd.DataFrame
497
+ layout : pd.DataFrame
498
+ x_scales, y_scales : list
499
+ params : dict
500
+
501
+ Returns
502
+ -------
503
+ pd.DataFrame
504
+ """
505
+ return data
506
+
507
+ def draw_panels(
508
+ self,
509
+ panels: list,
510
+ layout: pd.DataFrame,
511
+ x_scales: list,
512
+ y_scales: list,
513
+ ranges: list,
514
+ coord: Any,
515
+ data: Any,
516
+ theme: Any,
517
+ params: Dict[str, Any],
518
+ ) -> Any:
519
+ """Assemble panels into a gtable with background, axes, and labels.
520
+
521
+ Mirrors R's ``Facet$draw_panels`` → ``init_gtable`` → ``attach_axes``
522
+ pipeline (facet-.R:501-532):
523
+
524
+ 1. Create panel-only gtable with null units (init_gtable)
525
+ 2. Decorate each panel with coord background + foreground
526
+ 3. Render axis grobs, measure them, and attach as new rows/columns
527
+
528
+ Parameters
529
+ ----------
530
+ panels : list of grobs (per-layer, each containing per-panel grobs)
531
+ layout : pd.DataFrame
532
+ x_scales, y_scales : list
533
+ ranges : list of panel_params dicts
534
+ coord : Coord
535
+ data : list
536
+ theme : Theme
537
+ params : dict
538
+
539
+ Returns
540
+ -------
541
+ gtable
542
+ """
543
+ from grid_py import GTree, GList, null_grob, Viewport
544
+ from gtable_py import Gtable, gtable_add_grob, gtable_add_rows, gtable_add_cols
545
+ from grid_py import Unit as unit
546
+
547
+ nrow = int(layout["ROW"].max()) if len(layout) > 0 else 1
548
+ ncol = int(layout["COL"].max()) if len(layout) > 0 else 1
549
+
550
+ # ── Step 1: init_gtable — panel-only matrix (R: facet-.R:562-612)
551
+ # Panel sizes use "null" units (flexible, fill available space).
552
+ # Aspect ratio from coord is encoded in the null-unit ratio.
553
+ aspect_ratio = None
554
+ if hasattr(coord, "aspect") and ranges:
555
+ aspect_ratio = coord.aspect(ranges[0])
556
+
557
+ panel_h = abs(aspect_ratio) if aspect_ratio is not None else 1.0
558
+ widths = unit([1] * ncol, "null")
559
+ heights = unit([panel_h] * nrow, "null")
560
+ gt = Gtable(widths=widths, heights=heights, name="layout")
561
+
562
+ # Mark respect flag for aspect ratio (R: facet-.R:592)
563
+ if aspect_ratio is not None:
564
+ gt._respect = True
565
+
566
+ # ── Step 2: Place decorated panels into the gtable
567
+ for _, row_info in layout.iterrows():
568
+ panel_id = int(row_info["PANEL"])
569
+ r = int(row_info["ROW"])
570
+ c = int(row_info["COL"])
571
+ panel_idx = panel_id - 1
572
+ pp = ranges[panel_idx] if panel_idx < len(ranges) else {}
573
+
574
+ # Collect geom grobs for this panel
575
+ panel_grobs = []
576
+ for layer_grobs in panels:
577
+ if isinstance(layer_grobs, list) and panel_idx < len(layer_grobs):
578
+ panel_grobs.append(layer_grobs[panel_idx])
579
+ elif not isinstance(layer_grobs, list) and layer_grobs is not None:
580
+ panel_grobs.append(layer_grobs)
581
+
582
+ # Decorate panel with coord background + foreground
583
+ if hasattr(coord, "draw_panel"):
584
+ decorated = coord.draw_panel(panel_grobs, pp, theme)
585
+ else:
586
+ decorated = GTree(
587
+ children=GList(*panel_grobs),
588
+ name=f"panel-{panel_id}",
589
+ )
590
+
591
+ gt = gtable_add_grob(
592
+ gt, decorated, t=r, l=c, name=f"panel-{r}-{c}",
593
+ clip=getattr(coord, "clip", "on"),
594
+ )
595
+
596
+ # ── Step 3: Render axes and attach with measured sizes
597
+ # (R: facet-.R attach_axes → seam_table → max_height/max_width)
598
+ #
599
+ # Render axis grobs for each unique scale, then attach as new
600
+ # rows/columns with sizes from grob._height_cm / _width_cm.
601
+
602
+ # Collect axis grobs across all panels to find max sizes
603
+ left_axes = []
604
+ bottom_axes = []
605
+ top_axes = []
606
+ right_axes = []
607
+
608
+ for _, row_info in layout.iterrows():
609
+ panel_idx = int(row_info["PANEL"]) - 1
610
+ r = int(row_info["ROW"])
611
+ c = int(row_info["COL"])
612
+ pp = ranges[panel_idx] if panel_idx < len(ranges) else {}
613
+
614
+ if hasattr(coord, "render_axis_v"):
615
+ axes_v = coord.render_axis_v(pp, theme)
616
+ if c == 1:
617
+ left_ax = axes_v.get("left")
618
+ if left_ax is not None and not _is_null_grob(left_ax):
619
+ left_axes.append((r, left_ax))
620
+ if c == ncol:
621
+ right_ax = axes_v.get("right")
622
+ if right_ax is not None and not _is_null_grob(right_ax):
623
+ right_axes.append((r, right_ax))
624
+
625
+ if hasattr(coord, "render_axis_h"):
626
+ axes_h = coord.render_axis_h(pp, theme)
627
+ if r == nrow:
628
+ bottom_ax = axes_h.get("bottom")
629
+ if bottom_ax is not None and not _is_null_grob(bottom_ax):
630
+ bottom_axes.append((c, bottom_ax))
631
+ if r == 1:
632
+ top_ax = axes_h.get("top")
633
+ if top_ax is not None and not _is_null_grob(top_ax):
634
+ top_axes.append((c, top_ax))
635
+
636
+ # Track column offset from left-axis insertion
637
+ col_offset = 0
638
+
639
+ # ── Attach left axis (R: seam_table side="left")
640
+ if left_axes:
641
+ max_w = max(_axis_width_cm(ax) for _, ax in left_axes)
642
+ gt = gtable_add_cols(gt, unit([max_w], "cm"), pos=0)
643
+ col_offset = 1
644
+ for r, ax in left_axes:
645
+ gt = gtable_add_grob(
646
+ gt, ax, t=r, l=1, clip="off", name=f"axis-l-{r}",
647
+ )
648
+
649
+ # ── Attach right axis (R: seam_table side="right")
650
+ if right_axes:
651
+ max_w = max(_axis_width_cm(ax) for _, ax in right_axes)
652
+ gt = gtable_add_cols(gt, unit([max_w], "cm"), pos=-1)
653
+ ncol_now = len(gt._widths)
654
+ for r, ax in right_axes:
655
+ gt = gtable_add_grob(
656
+ gt, ax, t=r, l=ncol_now, clip="off", name=f"axis-r-{r}",
657
+ )
658
+
659
+ # ── Attach bottom axis (R: seam_table side="bottom")
660
+ if bottom_axes:
661
+ max_h = max(_axis_height_cm(ax) for _, ax in bottom_axes)
662
+ gt = gtable_add_rows(gt, unit([max_h], "cm"), pos=-1)
663
+ nrow_now = len(gt._heights)
664
+ for c, ax in bottom_axes:
665
+ gt = gtable_add_grob(
666
+ gt, ax, t=nrow_now, l=c + col_offset, clip="off",
667
+ name=f"axis-b-{c}",
668
+ )
669
+
670
+ # ── Attach top axis (R: seam_table side="top")
671
+ if top_axes:
672
+ max_h = max(_axis_height_cm(ax) for _, ax in top_axes)
673
+ gt = gtable_add_rows(gt, unit([max_h], "cm"), pos=0)
674
+ for c, ax in top_axes:
675
+ gt = gtable_add_grob(
676
+ gt, ax, t=1, l=c + col_offset, clip="off",
677
+ name=f"axis-t-{c}",
678
+ )
679
+
680
+ # ── Strip labels — pass col_offset so strips align with panels
681
+ gt = self._add_strip_labels(
682
+ gt, layout, nrow, ncol, params, theme, col_offset=col_offset,
683
+ )
684
+
685
+ return gt
686
+
687
+ def _add_strip_labels(
688
+ self,
689
+ gt: Any,
690
+ layout: pd.DataFrame,
691
+ nrow: int,
692
+ ncol: int,
693
+ params: Dict[str, Any],
694
+ theme: Any = None,
695
+ col_offset: int = 0,
696
+ ) -> Any:
697
+ """Add facet strip text labels to the gtable.
698
+
699
+ All visual properties resolved from theme via ``calc_element()``
700
+ for strip.background.x/y and strip.text.x/y.
701
+ """
702
+ from gtable_py import gtable_add_grob, gtable_add_rows, gtable_add_cols
703
+ from grid_py import Unit as unit, text_grob, Gpar, rect_grob
704
+ from grid_py._grob import grob_tree, GList
705
+ from ggplot2_py.theme_elements import calc_element as _calc_el
706
+
707
+ # R always has a complete theme at this point. If Python's theme
708
+ # is None, fall back to theme_grey() to surface real bugs rather
709
+ # than None-attribute errors.
710
+ if theme is None:
711
+ from ggplot2_py.theme_defaults import theme_grey
712
+ theme = theme_grey()
713
+
714
+ meta_cols = {"PANEL", "ROW", "COL", "SCALE_X", "SCALE_Y", "COORD"}
715
+ facet_vars = [c for c in layout.columns if c not in meta_cols]
716
+ if not facet_vars:
717
+ return gt
718
+
719
+ col_vars = _resolve_facet_vars(params.get("cols"))
720
+ row_vars = _resolve_facet_vars(params.get("rows"))
721
+ wrap_vars = _resolve_facet_vars(params.get("facets"))
722
+
723
+ # Resolve labeller function
724
+ from ggplot2_py.labeller import as_labeller, label_value
725
+ labeller_spec = params.get("labeller", "label_value")
726
+ try:
727
+ labeller_fn = as_labeller(labeller_spec)
728
+ except (ValueError, TypeError):
729
+ labeller_fn = label_value
730
+
731
+ # Resolve strip theme elements via calc_element (proper inheritance).
732
+ # R always has a complete theme with strip elements defined.
733
+ # If calc_element returns None, the element tree is incomplete
734
+ # — reset it and retry with a guaranteed-complete theme.
735
+ from ggplot2_py.theme_elements import ElementBlank as _EB
736
+ strip_txt_x_el = _calc_el("strip.text.x", theme)
737
+ if strip_txt_x_el is None:
738
+ from ggplot2_py.theme_elements import reset_theme_settings
739
+ reset_theme_settings()
740
+ from ggplot2_py.theme_defaults import theme_grey
741
+ theme = theme_grey()
742
+ strip_txt_x_el = _calc_el("strip.text.x", theme)
743
+ strip_bg_x_el = _calc_el("strip.background.x", theme)
744
+ strip_txt_y_el = _calc_el("strip.text.y", theme)
745
+ strip_bg_y_el = _calc_el("strip.background.y", theme)
746
+
747
+ def _props(el, attrs):
748
+ """Extract attrs from an element, returning None for ElementBlank.
749
+
750
+ R: when a strip element is ``element_blank()``, the
751
+ corresponding grob is simply a ``zeroGrob()``. We surface
752
+ this by mapping each attr to ``None``.
753
+ """
754
+ if el is None or isinstance(el, _EB):
755
+ return {k: None for k in attrs}
756
+ return {k: getattr(el, k, None) for k in attrs}
757
+
758
+ strip_txt_x = _props(strip_txt_x_el, ["colour", "size", "angle"])
759
+ strip_bg_x = _props(strip_bg_x_el, ["fill", "colour"])
760
+ strip_txt_y = _props(strip_txt_y_el, ["colour", "size", "angle"])
761
+ strip_bg_y = _props(strip_bg_y_el, ["fill", "colour"])
762
+
763
+ _txt_blank_x = isinstance(strip_txt_x_el, _EB)
764
+ _bg_blank_x = isinstance(strip_bg_x_el, _EB)
765
+ _txt_blank_y = isinstance(strip_txt_y_el, _EB)
766
+ _bg_blank_y = isinstance(strip_bg_y_el, _EB)
767
+
768
+ def _make_strip(label_text, bg_el, txt_el, rot, name,
769
+ bg_blank=False, txt_blank=False):
770
+ """Compose a strip: optional rect bg + optional text.
771
+
772
+ R: ElementBlank → zeroGrob, omitted from the output.
773
+ """
774
+ from grid_py import null_grob as _null
775
+ bg = (
776
+ _null()
777
+ if bg_blank or bg_el.get("fill") is None and bg_el.get("colour") is None
778
+ else rect_grob(
779
+ x=0.5, y=0.5, width=1, height=1,
780
+ gp=Gpar(fill=bg_el.get("fill"), col=bg_el.get("colour")),
781
+ name=f"strip.bg.{name}",
782
+ )
783
+ )
784
+ txt = (
785
+ _null()
786
+ if txt_blank or txt_el.get("size") is None
787
+ else text_grob(
788
+ label=label_text, x=0.5, y=0.5, rot=rot, just="centre",
789
+ gp=Gpar(fontsize=float(txt_el["size"]),
790
+ col=txt_el.get("colour")),
791
+ name=f"strip.text.{name}",
792
+ )
793
+ )
794
+ return grob_tree(bg, txt, name=f"strip-{name}")
795
+
796
+ def _get_strip_text(vars_list, row_info):
797
+ """Get formatted strip label text using the labeller."""
798
+ lab_dict = {v: [str(row_info.get(v, ""))] for v in vars_list}
799
+ result = labeller_fn(lab_dict)
800
+ return result[0] if result else ""
801
+
802
+ # Helper: measure strip text height/width in cm
803
+ # R: assemble_strips → max_height(grobs) / max_width(grobs)
804
+ from grid_py._size import calc_string_metric
805
+
806
+ def _strip_height_cm(labels, txt_el):
807
+ """Max height of strip labels (R: max_height(strip_grobs))."""
808
+ fs = float(txt_el.get("size") or 8)
809
+ max_h = 0.0
810
+ for lbl in labels:
811
+ m = calc_string_metric(str(lbl), Gpar(fontsize=fs))
812
+ max_h = max(max_h, (m["ascent"] + m["descent"]) * 2.54)
813
+ # Add small padding for strip background
814
+ return max(max_h + 0.1, 0.2)
815
+
816
+ def _strip_width_cm(labels, txt_el):
817
+ """Max width of strip labels (R: max_width(strip_grobs))."""
818
+ fs = float(txt_el.get("size") or 8)
819
+ max_w = 0.0
820
+ for lbl in labels:
821
+ m = calc_string_metric(str(lbl), Gpar(fontsize=fs))
822
+ max_w = max(max_w, (m["ascent"] + m["descent"]) * 2.54)
823
+ return max(max_w + 0.1, 0.2)
824
+
825
+ # --- facet_wrap ---
826
+ if wrap_vars and not col_vars and not row_vars:
827
+ # Collect all wrap labels to measure max height
828
+ all_wrap_labels = []
829
+ for _, row_info in layout.iterrows():
830
+ all_wrap_labels.append(_get_strip_text(wrap_vars, row_info))
831
+ strip_h = _strip_height_cm(all_wrap_labels, strip_txt_x)
832
+
833
+ for r in range(nrow, 0, -1):
834
+ gt = gtable_add_rows(gt, unit([strip_h], "cm"), pos=r - 1)
835
+ panels_in_row = layout[layout["ROW"] == r]
836
+ for _, row_info in panels_in_row.iterrows():
837
+ c = int(row_info["COL"])
838
+ label_text = _get_strip_text(wrap_vars, row_info)
839
+ strip = _make_strip(label_text, strip_bg_x, strip_txt_x, 0, f"w-{r}-{c}",
840
+ bg_blank=_bg_blank_x, txt_blank=_txt_blank_x)
841
+ gt = gtable_add_grob(gt, strip, t=r, l=c + col_offset,
842
+ clip="off", name=f"strip-w-{r}-{c}")
843
+ return gt
844
+
845
+ # --- Top strip (col vars) ---
846
+ if col_vars:
847
+ # Measure col strip labels
848
+ col_labels = []
849
+ for c in range(1, ncol + 1):
850
+ panel_row = layout[layout["COL"] == c].iloc[0]
851
+ col_labels.append(_get_strip_text(col_vars, panel_row))
852
+ strip_h = _strip_height_cm(col_labels, strip_txt_x) if not _txt_blank_x else 0.2
853
+
854
+ gt = gtable_add_rows(gt, unit([strip_h], "cm"), pos=0)
855
+ for c in range(1, ncol + 1):
856
+ panel_row = layout[layout["COL"] == c].iloc[0]
857
+ label_text = _get_strip_text(col_vars, panel_row)
858
+ strip = _make_strip(label_text, strip_bg_x, strip_txt_x, 0, f"t-{c}",
859
+ bg_blank=_bg_blank_x, txt_blank=_txt_blank_x)
860
+ gt = gtable_add_grob(gt, strip, t=1, l=c + col_offset,
861
+ clip="off", name=f"strip-t-{c}")
862
+
863
+ # --- Right strip (row vars) ---
864
+ if row_vars:
865
+ # Measure row strip labels (rotated text — width = text height)
866
+ row_labels = []
867
+ for r in range(1, nrow + 1):
868
+ panel_row = layout[layout["ROW"] == r].iloc[0]
869
+ row_labels.append(_get_strip_text(row_vars, panel_row))
870
+ strip_w = _strip_width_cm(row_labels, strip_txt_y) if not _txt_blank_y else 0.2
871
+
872
+ gt = gtable_add_cols(gt, unit([strip_w], "cm"), pos=-1)
873
+ ncol_now = len(gt._widths)
874
+ row_offset = 1 if col_vars else 0
875
+ for r in range(1, nrow + 1):
876
+ panel_row = layout[layout["ROW"] == r].iloc[0]
877
+ label_text = _get_strip_text(row_vars, panel_row)
878
+ rot = float(strip_txt_y.get("angle") or 0)
879
+ strip = _make_strip(label_text, strip_bg_y, strip_txt_y, rot, f"r-{r}",
880
+ bg_blank=_bg_blank_y, txt_blank=_txt_blank_y)
881
+ gt = gtable_add_grob(gt, strip, t=r + row_offset, l=ncol_now,
882
+ clip="off", name=f"strip-r-{r}")
883
+
884
+ return gt
885
+
886
+ def draw_labels(
887
+ self,
888
+ panels: Any,
889
+ layout: pd.DataFrame,
890
+ x_scales: list,
891
+ y_scales: list,
892
+ ranges: list,
893
+ coord: Any,
894
+ data: Any,
895
+ theme: Any,
896
+ labels: Dict[str, Any],
897
+ params: Dict[str, Any],
898
+ ) -> Any:
899
+ """Add axis title labels (xlab/ylab) to the panel table.
900
+
901
+ Mirrors R's ``Facet$draw_labels``: adds a bottom row for the
902
+ x-axis title and a left column for the y-axis title.
903
+
904
+ Parameters
905
+ ----------
906
+ panels : gtable
907
+ labels : dict
908
+ Rendered label grobs keyed by ``"x"`` / ``"y"``, each a
909
+ two-element list ``[primary, secondary]``.
910
+ """
911
+ from gtable_py import gtable_add_grob, gtable_add_rows, gtable_add_cols
912
+ from grid_py import Unit as unit, text_grob, Gpar, null_grob
913
+
914
+ from grid_py import grob_height, grob_width
915
+
916
+ gt = panels
917
+
918
+ # --- x-axis title (bottom) ---
919
+ x_label = None
920
+ if "x" in labels:
921
+ pair = labels["x"]
922
+ if isinstance(pair, list) and len(pair) > 0:
923
+ x_label = pair[0] # primary
924
+
925
+ if x_label is not None and not _is_null_grob(x_label):
926
+ # R: gtable_add_rows(table, grobHeight(xlab), pos=-1)
927
+ xlab_h = grob_height(x_label)
928
+ gt = gtable_add_rows(gt, xlab_h, pos=-1)
929
+ nrow = len(gt._heights)
930
+ ncol = len(gt._widths)
931
+ gt = gtable_add_grob(
932
+ gt, x_label, t=nrow, l=1, r=ncol,
933
+ clip="off", name="xlab",
934
+ )
935
+
936
+ # --- y-axis title (left) ---
937
+ y_label = None
938
+ if "y" in labels:
939
+ pair = labels["y"]
940
+ if isinstance(pair, list) and len(pair) > 0:
941
+ y_label = pair[0] # primary
942
+
943
+ if y_label is not None and not _is_null_grob(y_label):
944
+ # R: gtable_add_cols(table, grobWidth(ylab), pos=0)
945
+ ylab_w = grob_width(y_label)
946
+ gt = gtable_add_cols(gt, ylab_w, pos=0)
947
+ nrow = len(gt._heights)
948
+ gt = gtable_add_grob(
949
+ gt, y_label, t=1, b=nrow, l=1,
950
+ clip="off", name="ylab",
951
+ )
952
+
953
+ return gt
954
+
955
+ def vars(self) -> List[str]:
956
+ """Return the faceting variable names.
957
+
958
+ Returns
959
+ -------
960
+ list of str
961
+ """
962
+ return []
963
+
964
+
965
+ # ---------------------------------------------------------------------------
966
+ # FacetNull
967
+ # ---------------------------------------------------------------------------
968
+
969
+ class FacetNull(Facet):
970
+ """Single-panel facet (no faceting).
971
+
972
+ This is the default when no faceting is specified.
973
+ """
974
+
975
+ shrink: bool = True
976
+
977
+ def compute_layout(
978
+ self,
979
+ data: List[pd.DataFrame],
980
+ params: Dict[str, Any],
981
+ ) -> pd.DataFrame:
982
+ return _layout_null()
983
+
984
+ def map_data(
985
+ self,
986
+ data: pd.DataFrame,
987
+ layout: pd.DataFrame,
988
+ params: Dict[str, Any],
989
+ ) -> pd.DataFrame:
990
+ if is_waiver(data):
991
+ return pd.DataFrame({"PANEL": pd.Categorical([])})
992
+ if isinstance(data, pd.DataFrame) and len(data) == 0:
993
+ df = data.copy()
994
+ df["PANEL"] = pd.Categorical([])
995
+ return df
996
+ data = data.copy()
997
+ data["PANEL"] = pd.Categorical([1] * len(data))
998
+ return data
999
+
1000
+ def draw_panels(
1001
+ self,
1002
+ panels: list,
1003
+ layout: pd.DataFrame,
1004
+ x_scales: list,
1005
+ y_scales: list,
1006
+ ranges: list,
1007
+ coord: Any,
1008
+ data: Any,
1009
+ theme: Any,
1010
+ params: Dict[str, Any],
1011
+ ) -> Any:
1012
+ """Build a single-panel gtable with background, axes, and geom content.
1013
+
1014
+ Delegates to the base ``Facet.draw_panels`` which handles coord
1015
+ decoration and axis rendering.
1016
+ """
1017
+ return super().draw_panels(
1018
+ panels, layout, x_scales, y_scales, ranges,
1019
+ coord, data, theme, params,
1020
+ )
1021
+
1022
+
1023
+ # ---------------------------------------------------------------------------
1024
+ # FacetGrid
1025
+ # ---------------------------------------------------------------------------
1026
+
1027
+ class FacetGrid(Facet):
1028
+ """Grid facet: panels arranged in a row x column matrix.
1029
+
1030
+ Attributes
1031
+ ----------
1032
+ shrink : bool
1033
+ params : dict
1034
+ Contains ``rows``, ``cols``, ``scales``, ``space``, ``labeller``,
1035
+ ``as_table``, ``switch``, ``drop``, ``margins``, ``free``,
1036
+ ``space_free``, ``draw_axes``, ``axis_labels``.
1037
+ """
1038
+
1039
+ shrink: bool = True
1040
+
1041
+ def compute_layout(
1042
+ self,
1043
+ data: List[pd.DataFrame],
1044
+ params: Dict[str, Any],
1045
+ ) -> pd.DataFrame:
1046
+ """Compute a grid layout from data and parameters.
1047
+
1048
+ Parameters
1049
+ ----------
1050
+ data : list of DataFrame
1051
+ params : dict
1052
+
1053
+ Returns
1054
+ -------
1055
+ pd.DataFrame
1056
+ """
1057
+ row_vars = _resolve_facet_vars(params.get("rows"))
1058
+ col_vars = _resolve_facet_vars(params.get("cols"))
1059
+ drop = params.get("drop", True)
1060
+ free = params.get("free", {"x": False, "y": False})
1061
+
1062
+ base_rows = _combine_vars(data, row_vars, drop=drop) if row_vars else pd.DataFrame()
1063
+ base_cols = _combine_vars(data, col_vars, drop=drop) if col_vars else pd.DataFrame()
1064
+
1065
+ # Cross-product
1066
+ if len(base_rows) > 0 and len(base_cols) > 0:
1067
+ base_rows["_key_"] = 1
1068
+ base_cols["_key_"] = 1
1069
+ base = base_rows.merge(base_cols, on="_key_").drop("_key_", axis=1)
1070
+ elif len(base_rows) > 0:
1071
+ base = base_rows.copy()
1072
+ elif len(base_cols) > 0:
1073
+ base = base_cols.copy()
1074
+ else:
1075
+ return _layout_null()
1076
+
1077
+ if len(base) == 0:
1078
+ return _layout_null()
1079
+
1080
+ base = base.drop_duplicates().reset_index(drop=True)
1081
+
1082
+ # Assign PANEL
1083
+ n = len(base)
1084
+ base["PANEL"] = pd.Categorical(range(1, n + 1))
1085
+
1086
+ # ROW / COL identifiers
1087
+ if row_vars and any(v in base.columns for v in row_vars):
1088
+ present_rows = [v for v in row_vars if v in base.columns]
1089
+ row_ids = base[present_rows].apply(
1090
+ lambda r: "|".join(str(v) for v in r), axis=1
1091
+ )
1092
+ base["ROW"] = pd.Categorical(row_ids).codes + 1
1093
+ else:
1094
+ base["ROW"] = 1
1095
+
1096
+ if col_vars and any(v in base.columns for v in col_vars):
1097
+ present_cols = [v for v in col_vars if v in base.columns]
1098
+ col_ids = base[present_cols].apply(
1099
+ lambda r: "|".join(str(v) for v in r), axis=1
1100
+ )
1101
+ base["COL"] = pd.Categorical(col_ids).codes + 1
1102
+ else:
1103
+ base["COL"] = 1
1104
+
1105
+ # Scale identifiers
1106
+ base["SCALE_X"] = base["COL"] if free.get("x", False) else 1
1107
+ base["SCALE_Y"] = base["ROW"] if free.get("y", False) else 1
1108
+
1109
+ base = base.sort_values("PANEL").reset_index(drop=True)
1110
+ return base
1111
+
1112
+ def map_data(
1113
+ self,
1114
+ data: pd.DataFrame,
1115
+ layout: pd.DataFrame,
1116
+ params: Dict[str, Any],
1117
+ ) -> pd.DataFrame:
1118
+ row_vars = _resolve_facet_vars(params.get("rows"))
1119
+ col_vars = _resolve_facet_vars(params.get("cols"))
1120
+ all_vars = row_vars + col_vars
1121
+ return _map_facet_data(data, layout, params, all_vars)
1122
+
1123
+ def vars(self) -> List[str]:
1124
+ row_vars = _resolve_facet_vars(self.params.get("rows"))
1125
+ col_vars = _resolve_facet_vars(self.params.get("cols"))
1126
+ return row_vars + col_vars
1127
+
1128
+
1129
+ # ---------------------------------------------------------------------------
1130
+ # FacetWrap
1131
+ # ---------------------------------------------------------------------------
1132
+
1133
+ class FacetWrap(Facet):
1134
+ """Wrap facet: 1-d ribbon of panels wrapped into 2-d.
1135
+
1136
+ Attributes
1137
+ ----------
1138
+ shrink : bool
1139
+ params : dict
1140
+ Contains ``facets``, ``nrow``, ``ncol``, ``scales``, ``free``,
1141
+ ``space_free``, ``labeller``, ``strip_position``, ``dir``,
1142
+ ``drop``, ``draw_axes``, ``axis_labels``.
1143
+ """
1144
+
1145
+ shrink: bool = True
1146
+
1147
+ def compute_layout(
1148
+ self,
1149
+ data: List[pd.DataFrame],
1150
+ params: Dict[str, Any],
1151
+ ) -> pd.DataFrame:
1152
+ """Compute a wrapped layout.
1153
+
1154
+ Parameters
1155
+ ----------
1156
+ data : list of DataFrame
1157
+ params : dict
1158
+
1159
+ Returns
1160
+ -------
1161
+ pd.DataFrame
1162
+ """
1163
+ facet_vars = _resolve_facet_vars(params.get("facets"))
1164
+ drop = params.get("drop", True)
1165
+ free = params.get("free", {"x": False, "y": False})
1166
+ nrow = params.get("nrow")
1167
+ ncol = params.get("ncol")
1168
+ dir_ = params.get("dir", "lt")
1169
+
1170
+ if not facet_vars:
1171
+ return _layout_null()
1172
+
1173
+ base = _combine_vars(data, facet_vars, drop=drop)
1174
+ if len(base) == 0:
1175
+ return _layout_null()
1176
+
1177
+ base = base.drop_duplicates().reset_index(drop=True)
1178
+ n = len(base)
1179
+ dims = _wrap_dims(n, nrow, ncol)
1180
+
1181
+ # Assign PANEL, ROW, COL
1182
+ ids = np.arange(1, n + 1)
1183
+ base["PANEL"] = pd.Categorical(ids)
1184
+
1185
+ # Determine layout direction
1186
+ if len(dir_) == 2:
1187
+ row_vals, col_vals = _wrap_layout(ids, dims, dir_)
1188
+ else:
1189
+ # Fallback
1190
+ row_vals = (ids - 1) // dims[1] + 1
1191
+ col_vals = (ids - 1) % dims[1] + 1
1192
+
1193
+ base["ROW"] = row_vals.astype(int)
1194
+ base["COL"] = col_vals.astype(int)
1195
+
1196
+ # Scale identifiers
1197
+ base["SCALE_X"] = ids if free.get("x", False) else 1
1198
+ base["SCALE_Y"] = ids if free.get("y", False) else 1
1199
+
1200
+ base = base.sort_values("PANEL").reset_index(drop=True)
1201
+ return base
1202
+
1203
+ def map_data(
1204
+ self,
1205
+ data: pd.DataFrame,
1206
+ layout: pd.DataFrame,
1207
+ params: Dict[str, Any],
1208
+ ) -> pd.DataFrame:
1209
+ facet_vars = _resolve_facet_vars(params.get("facets"))
1210
+ return _map_facet_data(data, layout, params, facet_vars)
1211
+
1212
+ def vars(self) -> List[str]:
1213
+ return _resolve_facet_vars(self.params.get("facets"))
1214
+
1215
+
1216
+ def _wrap_layout(
1217
+ ids: np.ndarray,
1218
+ dims: Tuple[int, int],
1219
+ dir_: str,
1220
+ ) -> Tuple[np.ndarray, np.ndarray]:
1221
+ """Compute ROW and COL for wrapped layout.
1222
+
1223
+ Parameters
1224
+ ----------
1225
+ ids : np.ndarray
1226
+ 1-based panel IDs.
1227
+ dims : tuple of (nrow, ncol)
1228
+ dir_ : str
1229
+ Two-letter direction code.
1230
+
1231
+ Returns
1232
+ -------
1233
+ tuple of (ROW, COL) arrays
1234
+ """
1235
+ nrow, ncol = dims
1236
+ ids0 = ids - 1 # 0-based
1237
+
1238
+ if dir_ in ("lt", "lb"):
1239
+ row = ids0 // ncol
1240
+ col = ids0 % ncol
1241
+ elif dir_ in ("tl", "bl"):
1242
+ row = ids0 % nrow
1243
+ col = ids0 // nrow
1244
+ elif dir_ in ("rt", "rb"):
1245
+ row = ids0 // ncol
1246
+ col = ncol - 1 - ids0 % ncol
1247
+ elif dir_ in ("tr", "br"):
1248
+ row = ids0 % nrow
1249
+ col = ncol - 1 - ids0 // nrow
1250
+ else:
1251
+ row = ids0 // ncol
1252
+ col = ids0 % ncol
1253
+
1254
+ # Handle bottom-start directions
1255
+ if dir_ in ("lb", "bl", "rb", "br"):
1256
+ row = nrow - 1 - row
1257
+
1258
+ return row + 1, col + 1
1259
+
1260
+
1261
+ # ---------------------------------------------------------------------------
1262
+ # Constructor functions
1263
+ # ---------------------------------------------------------------------------
1264
+
1265
+ def facet_null(shrink: bool = True) -> FacetNull:
1266
+ """Create a null facet (single panel).
1267
+
1268
+ Parameters
1269
+ ----------
1270
+ shrink : bool
1271
+
1272
+ Returns
1273
+ -------
1274
+ FacetNull
1275
+ """
1276
+ obj = FacetNull()
1277
+ obj.shrink = shrink
1278
+ return obj
1279
+
1280
+
1281
+ def facet_grid(
1282
+ rows: Any = None,
1283
+ cols: Any = None,
1284
+ scales: str = "fixed",
1285
+ space: str = "fixed",
1286
+ shrink: bool = True,
1287
+ labeller: Any = "label_value",
1288
+ as_table: bool = True,
1289
+ switch: Optional[str] = None,
1290
+ drop: bool = True,
1291
+ margins: Union[bool, List[str]] = False,
1292
+ axes: str = "margins",
1293
+ axis_labels: str = "all",
1294
+ ) -> FacetGrid:
1295
+ """Create a grid facet.
1296
+
1297
+ Parameters
1298
+ ----------
1299
+ rows, cols : str, list, or None
1300
+ Faceting variables for rows and columns.
1301
+ scales : str
1302
+ ``"fixed"``, ``"free_x"``, ``"free_y"``, or ``"free"``.
1303
+ space : str
1304
+ ``"fixed"``, ``"free_x"``, ``"free_y"``, or ``"free"``.
1305
+ shrink : bool
1306
+ labeller : callable or str
1307
+ as_table : bool
1308
+ switch : str or None
1309
+ ``"x"``, ``"y"``, ``"both"``, or None.
1310
+ drop : bool
1311
+ margins : bool or list of str
1312
+ axes : str
1313
+ ``"margins"``, ``"all_x"``, ``"all_y"``, or ``"all"``.
1314
+ axis_labels : str
1315
+ ``"margins"``, ``"all_x"``, ``"all_y"``, or ``"all"``.
1316
+
1317
+ Returns
1318
+ -------
1319
+ FacetGrid
1320
+ """
1321
+ free = {
1322
+ "x": scales in ("free_x", "free"),
1323
+ "y": scales in ("free_y", "free"),
1324
+ }
1325
+ space_free = {
1326
+ "x": space in ("free_x", "free"),
1327
+ "y": space in ("free_y", "free"),
1328
+ }
1329
+ draw_axes_ = {
1330
+ "x": axes in ("all_x", "all"),
1331
+ "y": axes in ("all_y", "all"),
1332
+ }
1333
+ axis_labels_ = {
1334
+ "x": not draw_axes_["x"] or axis_labels in ("all_x", "all"),
1335
+ "y": not draw_axes_["y"] or axis_labels in ("all_y", "all"),
1336
+ }
1337
+
1338
+ obj = FacetGrid()
1339
+ obj.shrink = shrink
1340
+ obj.params = {
1341
+ "rows": rows,
1342
+ "cols": cols,
1343
+ "margins": margins,
1344
+ "free": free,
1345
+ "space_free": space_free,
1346
+ "labeller": labeller,
1347
+ "as_table": as_table,
1348
+ "switch": switch,
1349
+ "drop": drop,
1350
+ "draw_axes": draw_axes_,
1351
+ "axis_labels": axis_labels_,
1352
+ }
1353
+ return obj
1354
+
1355
+
1356
+ def facet_wrap(
1357
+ facets: Any,
1358
+ nrow: Optional[int] = None,
1359
+ ncol: Optional[int] = None,
1360
+ scales: str = "fixed",
1361
+ space: str = "fixed",
1362
+ shrink: bool = True,
1363
+ labeller: Any = "label_value",
1364
+ as_table: bool = True,
1365
+ drop: bool = True,
1366
+ dir: str = "h",
1367
+ strip_position: str = "top",
1368
+ axes: str = "margins",
1369
+ axis_labels: str = "all",
1370
+ ) -> FacetWrap:
1371
+ """Create a wrap facet.
1372
+
1373
+ Parameters
1374
+ ----------
1375
+ facets : str, list, or dict
1376
+ Faceting variables.
1377
+ nrow, ncol : int or None
1378
+ scales : str
1379
+ ``"fixed"``, ``"free_x"``, ``"free_y"``, or ``"free"``.
1380
+ space : str
1381
+ shrink : bool
1382
+ labeller : callable or str
1383
+ as_table : bool
1384
+ drop : bool
1385
+ dir : str
1386
+ Direction: ``"h"`` or ``"v"``, or a two-letter code.
1387
+ strip_position : str
1388
+ ``"top"``, ``"bottom"``, ``"left"``, or ``"right"``.
1389
+ axes : str
1390
+ axis_labels : str
1391
+
1392
+ Returns
1393
+ -------
1394
+ FacetWrap
1395
+ """
1396
+ free = {
1397
+ "x": scales in ("free_x", "free"),
1398
+ "y": scales in ("free_y", "free"),
1399
+ }
1400
+ space_free = {
1401
+ "x": space == "free_x",
1402
+ "y": space == "free_y",
1403
+ }
1404
+ draw_axes_ = {
1405
+ "x": free["x"] or axes in ("all_x", "all"),
1406
+ "y": free["y"] or axes in ("all_y", "all"),
1407
+ }
1408
+ axis_labels_ = {
1409
+ "x": free["x"] or not draw_axes_["x"] or axis_labels in ("all_x", "all"),
1410
+ "y": free["y"] or not draw_axes_["y"] or axis_labels in ("all_y", "all"),
1411
+ }
1412
+
1413
+ # Resolve direction
1414
+ if len(dir) == 1:
1415
+ if dir == "h":
1416
+ dir = "lt" if as_table else "lb"
1417
+ elif dir == "v":
1418
+ dir = "tl" if as_table else "tr"
1419
+
1420
+ if strip_position not in ("top", "bottom", "left", "right"):
1421
+ cli_abort("strip_position must be 'top', 'bottom', 'left', or 'right'.")
1422
+
1423
+ obj = FacetWrap()
1424
+ obj.shrink = shrink
1425
+ obj.params = {
1426
+ "facets": facets,
1427
+ "nrow": nrow,
1428
+ "ncol": ncol,
1429
+ "free": free,
1430
+ "space_free": space_free,
1431
+ "labeller": labeller,
1432
+ "dir": dir,
1433
+ "strip_position": strip_position,
1434
+ "drop": drop,
1435
+ "draw_axes": draw_axes_,
1436
+ "axis_labels": axis_labels_,
1437
+ }
1438
+ return obj
1439
+
1440
+
1441
+ # ---------------------------------------------------------------------------
1442
+ # Predicate
1443
+ # ---------------------------------------------------------------------------
1444
+
1445
+ def is_facet(x: Any) -> bool:
1446
+ """Test whether *x* is a Facet.
1447
+
1448
+ Parameters
1449
+ ----------
1450
+ x : object
1451
+
1452
+ Returns
1453
+ -------
1454
+ bool
1455
+ """
1456
+ return isinstance(x, Facet)