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.
- ggplot2_py/__init__.py +852 -0
- ggplot2_py/_compat.py +475 -0
- ggplot2_py/_plugins.py +129 -0
- ggplot2_py/_utils.py +544 -0
- ggplot2_py/aes.py +586 -0
- ggplot2_py/annotation.py +540 -0
- ggplot2_py/coord.py +2108 -0
- ggplot2_py/coords/__init__.py +49 -0
- ggplot2_py/datasets.py +265 -0
- ggplot2_py/draw_key.py +454 -0
- ggplot2_py/facet.py +1456 -0
- ggplot2_py/fortify.py +95 -0
- ggplot2_py/geom.py +4516 -0
- ggplot2_py/geoms/__init__.py +12 -0
- ggplot2_py/ggproto.py +279 -0
- ggplot2_py/guide.py +2925 -0
- ggplot2_py/guide_axis.py +615 -0
- ggplot2_py/guide_colourbar.py +657 -0
- ggplot2_py/guide_legend.py +1061 -0
- ggplot2_py/guides/__init__.py +8 -0
- ggplot2_py/labeller.py +296 -0
- ggplot2_py/labels.py +309 -0
- ggplot2_py/layer.py +954 -0
- ggplot2_py/layout.py +754 -0
- ggplot2_py/limits.py +314 -0
- ggplot2_py/plot.py +1401 -0
- ggplot2_py/plot_render.py +866 -0
- ggplot2_py/position.py +1269 -0
- ggplot2_py/protocols.py +171 -0
- ggplot2_py/py.typed +0 -0
- ggplot2_py/qplot.py +233 -0
- ggplot2_py/resources/diamonds.csv +53941 -0
- ggplot2_py/resources/economics.csv +575 -0
- ggplot2_py/resources/economics_long.csv +2871 -0
- ggplot2_py/resources/faithfuld.csv +5626 -0
- ggplot2_py/resources/luv_colours.csv +658 -0
- ggplot2_py/resources/midwest.csv +438 -0
- ggplot2_py/resources/mpg.csv +235 -0
- ggplot2_py/resources/msleep.csv +84 -0
- ggplot2_py/resources/presidential.csv +13 -0
- ggplot2_py/resources/seals.csv +1156 -0
- ggplot2_py/resources/txhousing.csv +8603 -0
- ggplot2_py/save.py +316 -0
- ggplot2_py/scale.py +2727 -0
- ggplot2_py/scales/__init__.py +4252 -0
- ggplot2_py/stat.py +6071 -0
- ggplot2_py/stats/__init__.py +9 -0
- ggplot2_py/theme.py +490 -0
- ggplot2_py/theme_defaults.py +1350 -0
- ggplot2_py/theme_elements.py +2052 -0
- ggplot2_python-4.0.2.9000.dist-info/METADATA +179 -0
- ggplot2_python-4.0.2.9000.dist-info/RECORD +54 -0
- ggplot2_python-4.0.2.9000.dist-info/WHEEL +4 -0
- 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)
|