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/plot.py
ADDED
|
@@ -0,0 +1,1401 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core ggplot class, build pipeline, and operator dispatch.
|
|
3
|
+
|
|
4
|
+
This module implements the central ``GGPlot`` object, the ``ggplot()``
|
|
5
|
+
constructor, the ``ggplot_build()`` pipeline, the ``+`` operator dispatch
|
|
6
|
+
(``ggplot_add`` / ``update_ggplot``), last-plot bookkeeping, and
|
|
7
|
+
plot-introspection utilities.
|
|
8
|
+
|
|
9
|
+
Rendering functions (``ggplot_gtable``, ``_table_add_legends``,
|
|
10
|
+
``_table_add_titles``, ``ggplotGrob``, ``print_plot``, ``find_panel``,
|
|
11
|
+
``panel_rows``, ``panel_cols``) are in ``plot_render.py``, mirroring R's
|
|
12
|
+
separation of ``plot-build.R`` / ``plot-render.R``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
import contextvars
|
|
19
|
+
import copy
|
|
20
|
+
import warnings
|
|
21
|
+
from functools import singledispatch
|
|
22
|
+
from typing import (
|
|
23
|
+
Any,
|
|
24
|
+
Callable,
|
|
25
|
+
Dict,
|
|
26
|
+
List,
|
|
27
|
+
Optional,
|
|
28
|
+
Sequence,
|
|
29
|
+
Tuple,
|
|
30
|
+
Type,
|
|
31
|
+
Union,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
import pandas as pd
|
|
36
|
+
|
|
37
|
+
from ggplot2_py._compat import (
|
|
38
|
+
Waiver,
|
|
39
|
+
is_waiver,
|
|
40
|
+
waiver,
|
|
41
|
+
cli_abort,
|
|
42
|
+
cli_warn,
|
|
43
|
+
cli_inform,
|
|
44
|
+
)
|
|
45
|
+
from ggplot2_py.ggproto import GGProto, ggproto, is_ggproto
|
|
46
|
+
from ggplot2_py.aes import Mapping, aes, is_mapping, standardise_aes_names
|
|
47
|
+
from ggplot2_py._utils import compact, modify_list, remove_missing, snake_class
|
|
48
|
+
from ggplot2_py.labels import Labels, is_labels, labs, make_labels, update_labels
|
|
49
|
+
from ggplot2_py.fortify import fortify
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"ggplot",
|
|
53
|
+
"is_ggplot",
|
|
54
|
+
"is_ggproto",
|
|
55
|
+
"ggplot_build",
|
|
56
|
+
"ggplot_gtable",
|
|
57
|
+
"ggplotGrob",
|
|
58
|
+
"ggplot_add",
|
|
59
|
+
"add_gg",
|
|
60
|
+
"get_last_plot",
|
|
61
|
+
"set_last_plot",
|
|
62
|
+
"last_plot",
|
|
63
|
+
"get_alt_text",
|
|
64
|
+
"update_ggplot",
|
|
65
|
+
"update_labels",
|
|
66
|
+
"by_layer",
|
|
67
|
+
"BuildStage",
|
|
68
|
+
"ggplot_defaults",
|
|
69
|
+
"get_layer_data",
|
|
70
|
+
"get_layer_grob",
|
|
71
|
+
"get_panel_scales",
|
|
72
|
+
"get_guide_data",
|
|
73
|
+
"get_strip_labels",
|
|
74
|
+
"get_labs",
|
|
75
|
+
"layer_data",
|
|
76
|
+
"layer_grob",
|
|
77
|
+
"layer_scales",
|
|
78
|
+
"summarise_plot",
|
|
79
|
+
"summarise_coord",
|
|
80
|
+
"summarise_layers",
|
|
81
|
+
"summarise_layout",
|
|
82
|
+
"find_panel",
|
|
83
|
+
"panel_rows",
|
|
84
|
+
"panel_cols",
|
|
85
|
+
"print_plot",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# Last-plot bookkeeping
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
_last_plot: Optional["GGPlot"] = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_last_plot() -> Optional["GGPlot"]:
|
|
97
|
+
"""Return the last plot created or displayed.
|
|
98
|
+
|
|
99
|
+
Returns
|
|
100
|
+
-------
|
|
101
|
+
GGPlot or None
|
|
102
|
+
"""
|
|
103
|
+
return _last_plot
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def set_last_plot(plot: "GGPlot") -> None:
|
|
107
|
+
"""Store *plot* as the last plot (used by ``ggsave`` etc.).
|
|
108
|
+
|
|
109
|
+
Parameters
|
|
110
|
+
----------
|
|
111
|
+
plot : GGPlot
|
|
112
|
+
Plot to record.
|
|
113
|
+
"""
|
|
114
|
+
global _last_plot
|
|
115
|
+
_last_plot = plot
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
last_plot = get_last_plot
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# Scoped defaults — ggplot_defaults context manager (Python-exclusive)
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
_ggplot_context: contextvars.ContextVar[Dict[str, Any]] = contextvars.ContextVar(
|
|
126
|
+
"_ggplot_context", default={}
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@contextlib.contextmanager
|
|
131
|
+
def ggplot_defaults(
|
|
132
|
+
*,
|
|
133
|
+
theme: Any = None,
|
|
134
|
+
coord: Any = None,
|
|
135
|
+
facet: Any = None,
|
|
136
|
+
mapping: Any = None,
|
|
137
|
+
):
|
|
138
|
+
"""Context manager for scoped plot defaults.
|
|
139
|
+
|
|
140
|
+
**Python-exclusive feature** — R has ``theme_set()`` for global state,
|
|
141
|
+
but no scoped equivalent. This context manager lets you set defaults
|
|
142
|
+
that apply to all :func:`ggplot` calls within the ``with`` block,
|
|
143
|
+
without affecting code outside.
|
|
144
|
+
|
|
145
|
+
Parameters
|
|
146
|
+
----------
|
|
147
|
+
theme : Theme or dict, optional
|
|
148
|
+
Default theme applied to all plots in scope.
|
|
149
|
+
coord : Coord, optional
|
|
150
|
+
Default coordinate system.
|
|
151
|
+
facet : Facet, optional
|
|
152
|
+
Default faceting specification.
|
|
153
|
+
mapping : Mapping, optional
|
|
154
|
+
Default aesthetic mapping.
|
|
155
|
+
|
|
156
|
+
Examples
|
|
157
|
+
--------
|
|
158
|
+
::
|
|
159
|
+
|
|
160
|
+
with ggplot_defaults(theme=theme_minimal(), coord=coord_fixed()):
|
|
161
|
+
p1 = ggplot(df, aes("x", "y")) + geom_point() # gets theme_minimal + coord_fixed
|
|
162
|
+
p2 = ggplot(df, aes("x", "y")) + geom_bar() # same defaults
|
|
163
|
+
# Outside: no defaults applied
|
|
164
|
+
"""
|
|
165
|
+
ctx: Dict[str, Any] = {}
|
|
166
|
+
if theme is not None:
|
|
167
|
+
ctx["theme"] = theme
|
|
168
|
+
if coord is not None:
|
|
169
|
+
ctx["coord"] = coord
|
|
170
|
+
if facet is not None:
|
|
171
|
+
ctx["facet"] = facet
|
|
172
|
+
if mapping is not None:
|
|
173
|
+
ctx["mapping"] = mapping
|
|
174
|
+
|
|
175
|
+
token = _ggplot_context.set(ctx)
|
|
176
|
+
try:
|
|
177
|
+
yield
|
|
178
|
+
finally:
|
|
179
|
+
_ggplot_context.reset(token)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _get_context_defaults() -> Dict[str, Any]:
|
|
183
|
+
"""Return the current scoped defaults (empty dict if none)."""
|
|
184
|
+
return _ggplot_context.get()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
# GGPlot class
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
class GGPlot:
|
|
192
|
+
"""A ggplot2 plot object.
|
|
193
|
+
|
|
194
|
+
``GGPlot`` is the central data structure. It stores the default data,
|
|
195
|
+
default mapping, layer stack, scales, coordinate system, faceting
|
|
196
|
+
specification, theme, labels, and guides.
|
|
197
|
+
|
|
198
|
+
Attributes
|
|
199
|
+
----------
|
|
200
|
+
data : DataFrame or Waiver or callable or None
|
|
201
|
+
Default data.
|
|
202
|
+
mapping : Mapping
|
|
203
|
+
Default aesthetic mapping.
|
|
204
|
+
layers : list of Layer
|
|
205
|
+
Layer stack.
|
|
206
|
+
scales : ScalesList
|
|
207
|
+
Scale container.
|
|
208
|
+
theme : Theme or dict
|
|
209
|
+
Theme specification.
|
|
210
|
+
coordinates : Coord
|
|
211
|
+
Coordinate system.
|
|
212
|
+
facet : Facet
|
|
213
|
+
Faceting specification.
|
|
214
|
+
labels : Labels
|
|
215
|
+
Axis / title labels.
|
|
216
|
+
guides : object
|
|
217
|
+
Guides specification.
|
|
218
|
+
plot_env : object
|
|
219
|
+
The environment the plot was created in (unused in Python).
|
|
220
|
+
layout : type
|
|
221
|
+
Layout class used during the build.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
def __init__(
|
|
225
|
+
self,
|
|
226
|
+
data: Any = None,
|
|
227
|
+
mapping: Optional[Mapping] = None,
|
|
228
|
+
*,
|
|
229
|
+
plot_env: Any = None,
|
|
230
|
+
) -> None:
|
|
231
|
+
# Lazy imports to avoid circular dependencies
|
|
232
|
+
from ggplot2_py.scale import ScalesList
|
|
233
|
+
from ggplot2_py.theme import Theme
|
|
234
|
+
|
|
235
|
+
self.data = data
|
|
236
|
+
self.mapping: Mapping = mapping if mapping is not None else aes()
|
|
237
|
+
self.layers: List[Any] = []
|
|
238
|
+
self.scales: "ScalesList" = ScalesList()
|
|
239
|
+
# R: plot$theme starts as an empty theme() object — not a bare list.
|
|
240
|
+
# Must be a Theme instance so complete_theme() can access .complete
|
|
241
|
+
# and ggplot2_py.theme.add_theme() works via operator overloads.
|
|
242
|
+
self.theme: Theme = Theme()
|
|
243
|
+
self.coordinates: Any = None # filled lazily via default
|
|
244
|
+
self.facet: Any = None # filled lazily via default
|
|
245
|
+
self.labels: Labels = Labels()
|
|
246
|
+
self.guides: Any = None
|
|
247
|
+
self.plot_env: Any = plot_env
|
|
248
|
+
self.layout: Any = None # Layout class reference
|
|
249
|
+
self._meta: Dict[str, Any] = {}
|
|
250
|
+
self._build_hooks: Dict[Tuple[str, str], List[Callable]] = {}
|
|
251
|
+
|
|
252
|
+
# Apply scoped context defaults (Python-exclusive feature).
|
|
253
|
+
ctx = _get_context_defaults()
|
|
254
|
+
if ctx:
|
|
255
|
+
if "theme" in ctx and not self.theme:
|
|
256
|
+
self.theme = ctx["theme"]
|
|
257
|
+
if "coord" in ctx and self.coordinates is None:
|
|
258
|
+
self.coordinates = ctx["coord"]
|
|
259
|
+
if "facet" in ctx and self.facet is None:
|
|
260
|
+
self.facet = ctx["facet"]
|
|
261
|
+
if "mapping" in ctx:
|
|
262
|
+
# Merge: context defaults as base, explicit mapping overrides
|
|
263
|
+
merged = aes(**{**ctx["mapping"], **self.mapping})
|
|
264
|
+
self.mapping = merged
|
|
265
|
+
|
|
266
|
+
# ------------------------------------------------------------------
|
|
267
|
+
# Clone
|
|
268
|
+
# ------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
def _clone(self) -> "GGPlot":
|
|
271
|
+
"""Create a shallow copy with a cloned scales list.
|
|
272
|
+
|
|
273
|
+
Returns
|
|
274
|
+
-------
|
|
275
|
+
GGPlot
|
|
276
|
+
"""
|
|
277
|
+
p = copy.copy(self)
|
|
278
|
+
p.scales = self.scales.clone()
|
|
279
|
+
p.layers = list(self.layers) # shallow copy of list
|
|
280
|
+
p.labels = Labels(self.labels)
|
|
281
|
+
return p
|
|
282
|
+
|
|
283
|
+
# ------------------------------------------------------------------
|
|
284
|
+
# Build hooks (Python-exclusive — no R equivalent)
|
|
285
|
+
# ------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
def add_build_hook(
|
|
288
|
+
self,
|
|
289
|
+
timing: str,
|
|
290
|
+
stage: str,
|
|
291
|
+
fn: Callable,
|
|
292
|
+
) -> "GGPlot":
|
|
293
|
+
"""Register a callback on a named pipeline stage.
|
|
294
|
+
|
|
295
|
+
**Python-exclusive feature** — R's ggplot2 does not support
|
|
296
|
+
build-stage hooks.
|
|
297
|
+
|
|
298
|
+
Parameters
|
|
299
|
+
----------
|
|
300
|
+
timing : ``"before"`` or ``"after"``
|
|
301
|
+
Whether to run before or after the named stage.
|
|
302
|
+
stage : str
|
|
303
|
+
A :class:`BuildStage` constant (e.g.
|
|
304
|
+
``BuildStage.COMPUTE_STAT``).
|
|
305
|
+
fn : callable
|
|
306
|
+
``fn(data, **ctx) -> data_or_None``. Receives the current
|
|
307
|
+
per-layer data list. Return a new list to replace it, or
|
|
308
|
+
``None`` to leave it unchanged.
|
|
309
|
+
|
|
310
|
+
Returns
|
|
311
|
+
-------
|
|
312
|
+
GGPlot
|
|
313
|
+
``self`` (for chaining).
|
|
314
|
+
"""
|
|
315
|
+
if timing not in ("before", "after"):
|
|
316
|
+
raise ValueError(f"timing must be 'before' or 'after', got {timing!r}")
|
|
317
|
+
key = (timing, stage)
|
|
318
|
+
self._build_hooks.setdefault(key, []).append(fn)
|
|
319
|
+
return self
|
|
320
|
+
|
|
321
|
+
# ------------------------------------------------------------------
|
|
322
|
+
# + operator
|
|
323
|
+
# ------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
def __add__(self, other: Any) -> "GGPlot":
|
|
326
|
+
if other is None:
|
|
327
|
+
return self
|
|
328
|
+
p = self._clone()
|
|
329
|
+
p = ggplot_add(other, p)
|
|
330
|
+
set_last_plot(p)
|
|
331
|
+
return p
|
|
332
|
+
|
|
333
|
+
def __radd__(self, other: Any) -> "GGPlot":
|
|
334
|
+
if other is None or other == 0:
|
|
335
|
+
return self
|
|
336
|
+
return self.__add__(other)
|
|
337
|
+
|
|
338
|
+
def __iadd__(self, other: Any) -> "GGPlot":
|
|
339
|
+
return self.__add__(other)
|
|
340
|
+
|
|
341
|
+
# ------------------------------------------------------------------
|
|
342
|
+
# Attribute access helpers
|
|
343
|
+
# ------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
def __getattr__(self, name: str) -> Any:
|
|
346
|
+
if name.startswith("_"):
|
|
347
|
+
raise AttributeError(name)
|
|
348
|
+
meta = object.__getattribute__(self, "_meta")
|
|
349
|
+
if name in meta:
|
|
350
|
+
return meta[name]
|
|
351
|
+
raise AttributeError(
|
|
352
|
+
f"'{type(self).__name__}' object has no attribute '{name}'"
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
356
|
+
# Direct attributes go to __dict__; others to _meta
|
|
357
|
+
if name in (
|
|
358
|
+
"data", "mapping", "layers", "scales", "theme",
|
|
359
|
+
"coordinates", "facet", "labels", "guides", "plot_env",
|
|
360
|
+
"layout", "_meta", "_build_hooks",
|
|
361
|
+
# Rendering hints — user can override before notebook display.
|
|
362
|
+
"fig_width", "fig_height", "fig_dpi",
|
|
363
|
+
):
|
|
364
|
+
object.__setattr__(self, name, value)
|
|
365
|
+
else:
|
|
366
|
+
try:
|
|
367
|
+
meta = object.__getattribute__(self, "_meta")
|
|
368
|
+
except AttributeError:
|
|
369
|
+
object.__setattr__(self, name, value)
|
|
370
|
+
return
|
|
371
|
+
meta[name] = value
|
|
372
|
+
|
|
373
|
+
# ------------------------------------------------------------------
|
|
374
|
+
# Repr / summary
|
|
375
|
+
# ------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
def __repr__(self) -> str:
|
|
378
|
+
n_layers = len(self.layers)
|
|
379
|
+
data_info = ""
|
|
380
|
+
if isinstance(self.data, pd.DataFrame):
|
|
381
|
+
data_info = f" data={self.data.shape[0]}x{self.data.shape[1]}"
|
|
382
|
+
return f"<GGPlot{data_info} layers={n_layers}>"
|
|
383
|
+
|
|
384
|
+
# Default display size (inches) and DPI for Jupyter rendering.
|
|
385
|
+
# Override per-plot: ``p.fig_width = 12; p.fig_height = 8``
|
|
386
|
+
# Override globally: ``GGPlot.fig_width = 12``
|
|
387
|
+
fig_width: float = 7.0
|
|
388
|
+
fig_height: float = 5.0
|
|
389
|
+
fig_dpi: int = 150
|
|
390
|
+
|
|
391
|
+
def _repr_png_(self) -> Optional[bytes]:
|
|
392
|
+
"""Render the plot as PNG bytes for Jupyter notebook display."""
|
|
393
|
+
from grid_py import grid_draw, grid_newpage
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
grid_newpage(
|
|
397
|
+
width=self.fig_width,
|
|
398
|
+
height=self.fig_height,
|
|
399
|
+
dpi=float(self.fig_dpi),
|
|
400
|
+
)
|
|
401
|
+
built = ggplot_build(self)
|
|
402
|
+
gtable = ggplot_gtable(built)
|
|
403
|
+
grid_draw(gtable)
|
|
404
|
+
|
|
405
|
+
from grid_py import get_state
|
|
406
|
+
renderer = get_state().get_renderer()
|
|
407
|
+
if renderer is not None:
|
|
408
|
+
return renderer.to_png_bytes()
|
|
409
|
+
return None
|
|
410
|
+
except Exception:
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
def summary(self) -> str:
|
|
414
|
+
"""Return a human-readable summary of the plot.
|
|
415
|
+
|
|
416
|
+
Returns
|
|
417
|
+
-------
|
|
418
|
+
str
|
|
419
|
+
"""
|
|
420
|
+
parts: List[str] = []
|
|
421
|
+
if isinstance(self.data, pd.DataFrame) and not self.data.empty:
|
|
422
|
+
cols = ", ".join(self.data.columns[:10])
|
|
423
|
+
parts.append(
|
|
424
|
+
f"data: {cols} [{self.data.shape[0]}x{self.data.shape[1]}]"
|
|
425
|
+
)
|
|
426
|
+
if self.mapping:
|
|
427
|
+
parts.append(f"mapping: {self.mapping}")
|
|
428
|
+
if self.scales.n() > 0:
|
|
429
|
+
parts.append(f"scales: {', '.join(self.scales.input())}")
|
|
430
|
+
if self.facet is not None and hasattr(self.facet, "vars"):
|
|
431
|
+
fv = self.facet.vars()
|
|
432
|
+
parts.append(f"faceting: {', '.join(fv) if fv else '<none>'}")
|
|
433
|
+
if self.layers:
|
|
434
|
+
parts.append("---")
|
|
435
|
+
for layer in self.layers:
|
|
436
|
+
parts.append(f" {layer}")
|
|
437
|
+
return "\n".join(parts)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def is_ggplot(x: Any) -> bool:
|
|
441
|
+
"""Return ``True`` if *x* is a :class:`GGPlot` instance.
|
|
442
|
+
|
|
443
|
+
Parameters
|
|
444
|
+
----------
|
|
445
|
+
x : object
|
|
446
|
+
Object to test.
|
|
447
|
+
|
|
448
|
+
Returns
|
|
449
|
+
-------
|
|
450
|
+
bool
|
|
451
|
+
"""
|
|
452
|
+
return isinstance(x, GGPlot)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# ---------------------------------------------------------------------------
|
|
456
|
+
# ggplot() constructor
|
|
457
|
+
# ---------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
def ggplot(
|
|
460
|
+
data: Any = None,
|
|
461
|
+
mapping: Optional[Mapping] = None,
|
|
462
|
+
**kwargs: Any,
|
|
463
|
+
) -> GGPlot:
|
|
464
|
+
"""Create a new ggplot object.
|
|
465
|
+
|
|
466
|
+
Parameters
|
|
467
|
+
----------
|
|
468
|
+
data : DataFrame or dict or None, optional
|
|
469
|
+
Default dataset for the plot. Will be converted to a DataFrame
|
|
470
|
+
via :func:`fortify` if necessary.
|
|
471
|
+
mapping : Mapping, optional
|
|
472
|
+
Default aesthetic mapping created via :func:`aes`.
|
|
473
|
+
**kwargs
|
|
474
|
+
Additional keyword arguments (currently unused).
|
|
475
|
+
|
|
476
|
+
Returns
|
|
477
|
+
-------
|
|
478
|
+
GGPlot
|
|
479
|
+
A new plot object ready for layers to be added with ``+``.
|
|
480
|
+
|
|
481
|
+
Examples
|
|
482
|
+
--------
|
|
483
|
+
>>> import pandas as pd
|
|
484
|
+
>>> df = pd.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]})
|
|
485
|
+
>>> p = ggplot(df, aes(x="x", y="y"))
|
|
486
|
+
"""
|
|
487
|
+
if callable(data) and not isinstance(data, (pd.DataFrame, dict, type)):
|
|
488
|
+
cli_abort(
|
|
489
|
+
"`data` cannot be a function. "
|
|
490
|
+
"Have you misspelled the `data` argument in `ggplot()`?",
|
|
491
|
+
cls=TypeError,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
if mapping is not None and not isinstance(mapping, Mapping):
|
|
495
|
+
# Maybe data and mapping were swapped?
|
|
496
|
+
if isinstance(mapping, (pd.DataFrame, dict)):
|
|
497
|
+
data, mapping = mapping, data
|
|
498
|
+
elif is_mapping(mapping):
|
|
499
|
+
pass
|
|
500
|
+
else:
|
|
501
|
+
cli_warn(
|
|
502
|
+
f"Unexpected type for `mapping`: {type(mapping).__name__}. "
|
|
503
|
+
"Expected a Mapping from `aes()`."
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
# Validate / convert mapping
|
|
507
|
+
if mapping is None:
|
|
508
|
+
mapping = aes()
|
|
509
|
+
|
|
510
|
+
# Fortify data
|
|
511
|
+
data = fortify(data)
|
|
512
|
+
|
|
513
|
+
p = GGPlot(data=data, mapping=mapping)
|
|
514
|
+
|
|
515
|
+
# Set defaults lazily (coord and facet are imported here to avoid circulars)
|
|
516
|
+
from ggplot2_py.coord import CoordCartesian
|
|
517
|
+
from ggplot2_py.facet import FacetNull
|
|
518
|
+
|
|
519
|
+
p.coordinates = CoordCartesian()
|
|
520
|
+
p.coordinates.default = True
|
|
521
|
+
p.facet = FacetNull()
|
|
522
|
+
|
|
523
|
+
# Set initial labels from mapping
|
|
524
|
+
p.labels = labs(**make_labels(mapping))
|
|
525
|
+
|
|
526
|
+
set_last_plot(p)
|
|
527
|
+
return p
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
# ---------------------------------------------------------------------------
|
|
531
|
+
# BuiltGGPlot
|
|
532
|
+
# ---------------------------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
class BuiltGGPlot:
|
|
535
|
+
"""Container returned by :func:`ggplot_build`.
|
|
536
|
+
|
|
537
|
+
Attributes
|
|
538
|
+
----------
|
|
539
|
+
data : list of DataFrame
|
|
540
|
+
Computed data for each layer.
|
|
541
|
+
layout : Layout
|
|
542
|
+
Trained layout object.
|
|
543
|
+
plot : GGPlot
|
|
544
|
+
The (possibly modified) plot object.
|
|
545
|
+
"""
|
|
546
|
+
|
|
547
|
+
def __init__(
|
|
548
|
+
self,
|
|
549
|
+
data: List[pd.DataFrame],
|
|
550
|
+
layout: Any,
|
|
551
|
+
plot: GGPlot,
|
|
552
|
+
) -> None:
|
|
553
|
+
self.data = data
|
|
554
|
+
self.layout = layout
|
|
555
|
+
self.plot = plot
|
|
556
|
+
|
|
557
|
+
def __repr__(self) -> str:
|
|
558
|
+
return f"<BuiltGGPlot layers={len(self.data)}>"
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
# ---------------------------------------------------------------------------
|
|
562
|
+
# ---------------------------------------------------------------------------
|
|
563
|
+
# BuildStage — named pipeline stage constants (Python-exclusive feature).
|
|
564
|
+
#
|
|
565
|
+
# R's pipeline is fixed-sequence with no hook points. This enum-like class
|
|
566
|
+
# gives each stage a stable name so that the hook system (below) can target
|
|
567
|
+
# specific stages.
|
|
568
|
+
# ---------------------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
class BuildStage:
|
|
572
|
+
"""Named constants for the ``ggplot_build`` pipeline stages.
|
|
573
|
+
|
|
574
|
+
These are used with :meth:`GGPlot.add_build_hook` to register
|
|
575
|
+
before/after callbacks on specific pipeline stages. This is a
|
|
576
|
+
**Python-exclusive** extension point — R's ggplot2 does not expose
|
|
577
|
+
hooks on individual build stages.
|
|
578
|
+
|
|
579
|
+
Example
|
|
580
|
+
-------
|
|
581
|
+
::
|
|
582
|
+
|
|
583
|
+
p = ggplot(df, aes("x", "y"))
|
|
584
|
+
p.add_build_hook("after", BuildStage.COMPUTE_STAT, my_callback)
|
|
585
|
+
"""
|
|
586
|
+
|
|
587
|
+
LAYER_DATA = "layer_data"
|
|
588
|
+
SETUP_LAYER = "setup_layer"
|
|
589
|
+
SETUP_LAYOUT = "setup_layout"
|
|
590
|
+
COMPUTE_AESTHETICS = "compute_aesthetics"
|
|
591
|
+
TRANSFORM_SCALES = "transform_scales"
|
|
592
|
+
TRAIN_POSITION = "train_position"
|
|
593
|
+
COMPUTE_STAT = "compute_stat"
|
|
594
|
+
MAP_STAT = "map_stat"
|
|
595
|
+
COMPUTE_GEOM_1 = "compute_geom_1"
|
|
596
|
+
COMPUTE_POSITION = "compute_position"
|
|
597
|
+
RETRAIN_POSITION = "retrain_position"
|
|
598
|
+
SETUP_GUIDES = "setup_guides"
|
|
599
|
+
TRAIN_NONPOSITION = "train_nonposition"
|
|
600
|
+
COMPUTE_GEOM_2 = "compute_geom_2"
|
|
601
|
+
FINISH_STAT = "finish_stat"
|
|
602
|
+
FINISH_DATA = "finish_data"
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _run_hooks(
|
|
606
|
+
plot: GGPlot,
|
|
607
|
+
timing: str,
|
|
608
|
+
stage: str,
|
|
609
|
+
data: List[Any],
|
|
610
|
+
**ctx: Any,
|
|
611
|
+
) -> List[Any]:
|
|
612
|
+
"""Execute registered build hooks for (*timing*, *stage*).
|
|
613
|
+
|
|
614
|
+
Parameters
|
|
615
|
+
----------
|
|
616
|
+
plot : GGPlot
|
|
617
|
+
The plot whose hooks to run.
|
|
618
|
+
timing : ``"before"`` or ``"after"``
|
|
619
|
+
When relative to the stage.
|
|
620
|
+
stage : str
|
|
621
|
+
One of the :class:`BuildStage` constants.
|
|
622
|
+
data : list
|
|
623
|
+
Current per-layer data list.
|
|
624
|
+
**ctx
|
|
625
|
+
Additional context (e.g. ``layout``, ``scales``) passed to hooks.
|
|
626
|
+
|
|
627
|
+
Returns
|
|
628
|
+
-------
|
|
629
|
+
list
|
|
630
|
+
Possibly modified per-layer data.
|
|
631
|
+
"""
|
|
632
|
+
hooks = getattr(plot, "_build_hooks", None)
|
|
633
|
+
if not hooks:
|
|
634
|
+
return data
|
|
635
|
+
for hook in hooks.get((timing, stage), []):
|
|
636
|
+
result = hook(data, **ctx)
|
|
637
|
+
if result is not None:
|
|
638
|
+
data = result
|
|
639
|
+
return data
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
# ---------------------------------------------------------------------------
|
|
643
|
+
# by_layer — apply a function per-layer with error context
|
|
644
|
+
# (R ref: plot-build.R:194-211)
|
|
645
|
+
# ---------------------------------------------------------------------------
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def by_layer(
|
|
649
|
+
fn: Callable,
|
|
650
|
+
layers: List[Any],
|
|
651
|
+
data: List[Any],
|
|
652
|
+
step: str = "",
|
|
653
|
+
) -> List[Any]:
|
|
654
|
+
"""Apply *fn(layer, data_i)* for each layer.
|
|
655
|
+
|
|
656
|
+
Mirrors R's ``by_layer()`` helper in *plot-build.R:194-211*. Wraps
|
|
657
|
+
each call in a try/except so that errors include the layer index.
|
|
658
|
+
|
|
659
|
+
Parameters
|
|
660
|
+
----------
|
|
661
|
+
fn : callable
|
|
662
|
+
``fn(layer, data_i) -> data_i`` to apply.
|
|
663
|
+
layers : list
|
|
664
|
+
Layer objects.
|
|
665
|
+
data : list
|
|
666
|
+
Parallel list of per-layer DataFrames.
|
|
667
|
+
step : str
|
|
668
|
+
Human-readable description of the current pipeline stage
|
|
669
|
+
(used in error messages).
|
|
670
|
+
|
|
671
|
+
Returns
|
|
672
|
+
-------
|
|
673
|
+
list
|
|
674
|
+
Updated per-layer DataFrames.
|
|
675
|
+
"""
|
|
676
|
+
out: List[Any] = [None] * len(data)
|
|
677
|
+
for i in range(len(data)):
|
|
678
|
+
try:
|
|
679
|
+
out[i] = fn(layers[i], data[i])
|
|
680
|
+
except Exception as e:
|
|
681
|
+
raise RuntimeError(
|
|
682
|
+
f"Problem while {step}: error in layer {i + 1}."
|
|
683
|
+
) from e
|
|
684
|
+
return out
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
# ---------------------------------------------------------------------------
|
|
688
|
+
# ggplot_build
|
|
689
|
+
# ---------------------------------------------------------------------------
|
|
690
|
+
|
|
691
|
+
@singledispatch
|
|
692
|
+
def ggplot_build(plot: Any) -> BuiltGGPlot:
|
|
693
|
+
"""Build a ggplot for rendering.
|
|
694
|
+
|
|
695
|
+
This is a :func:`functools.singledispatch` generic (R ref:
|
|
696
|
+
``plot-build.R:28``, ``UseMethod("ggplot_build")``). Extension
|
|
697
|
+
packages can register custom plot types::
|
|
698
|
+
|
|
699
|
+
@ggplot_build.register(MyPlotClass)
|
|
700
|
+
def _build_my_plot(plot):
|
|
701
|
+
...
|
|
702
|
+
|
|
703
|
+
Parameters
|
|
704
|
+
----------
|
|
705
|
+
plot : GGPlot or BuiltGGPlot
|
|
706
|
+
The plot to build.
|
|
707
|
+
|
|
708
|
+
Returns
|
|
709
|
+
-------
|
|
710
|
+
BuiltGGPlot
|
|
711
|
+
"""
|
|
712
|
+
raise TypeError(
|
|
713
|
+
f"Cannot build object of type {type(plot).__name__}. "
|
|
714
|
+
"Expected a GGPlot or BuiltGGPlot instance."
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
@ggplot_build.register(BuiltGGPlot)
|
|
719
|
+
def _build_noop(plot):
|
|
720
|
+
"""Already-built plots are returned unchanged (R: no-op method)."""
|
|
721
|
+
return plot
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
@ggplot_build.register(GGPlot)
|
|
725
|
+
def _build_ggplot(plot):
|
|
726
|
+
"""Build a GGPlot through the full data pipeline."""
|
|
727
|
+
from ggplot2_py.layout import Layout, create_layout
|
|
728
|
+
from ggplot2_py.theme import complete_theme
|
|
729
|
+
|
|
730
|
+
plot = plot._clone()
|
|
731
|
+
|
|
732
|
+
# Ensure at least one layer
|
|
733
|
+
if len(plot.layers) == 0:
|
|
734
|
+
# Add a blank layer
|
|
735
|
+
try:
|
|
736
|
+
from ggplot2_py.geom import geom_blank
|
|
737
|
+
blank = geom_blank()
|
|
738
|
+
plot.layers.append(blank)
|
|
739
|
+
except ImportError:
|
|
740
|
+
pass
|
|
741
|
+
|
|
742
|
+
layers = plot.layers
|
|
743
|
+
data: List[Optional[pd.DataFrame]] = [None] * len(layers)
|
|
744
|
+
scales = plot.scales
|
|
745
|
+
|
|
746
|
+
_h = _run_hooks # local alias for brevity
|
|
747
|
+
S = BuildStage
|
|
748
|
+
|
|
749
|
+
# --- Layer data ---
|
|
750
|
+
data = _h(plot, "before", S.LAYER_DATA, data)
|
|
751
|
+
data = by_layer(lambda l, d: l.layer_data(plot.data), layers, data, "computing layer data")
|
|
752
|
+
data = _h(plot, "after", S.LAYER_DATA, data)
|
|
753
|
+
|
|
754
|
+
# --- Setup layers ---
|
|
755
|
+
data = _h(plot, "before", S.SETUP_LAYER, data)
|
|
756
|
+
data = by_layer(lambda l, d: l.setup_layer(d, plot), layers, data, "setting up layer")
|
|
757
|
+
data = _h(plot, "after", S.SETUP_LAYER, data)
|
|
758
|
+
|
|
759
|
+
# --- Setup layout ---
|
|
760
|
+
layout = create_layout(plot.facet, plot.coordinates, getattr(plot, "layout", None))
|
|
761
|
+
data = layout.setup(data, plot.data if isinstance(plot.data, pd.DataFrame) else pd.DataFrame(), plot.plot_env)
|
|
762
|
+
|
|
763
|
+
# --- Compute aesthetics ---
|
|
764
|
+
data = _h(plot, "before", S.COMPUTE_AESTHETICS, data)
|
|
765
|
+
data = by_layer(lambda l, d: l.compute_aesthetics(d, plot), layers, data, "computing aesthetics")
|
|
766
|
+
data = _h(plot, "after", S.COMPUTE_AESTHETICS, data)
|
|
767
|
+
|
|
768
|
+
# --- Add default scales ---
|
|
769
|
+
for i in range(len(data)):
|
|
770
|
+
if data[i] is not None and not data[i].empty:
|
|
771
|
+
scales.add_defaults(data[i], plot.plot_env)
|
|
772
|
+
|
|
773
|
+
# --- Setup plot labels ---
|
|
774
|
+
_setup_plot_labels(plot, layers, data)
|
|
775
|
+
|
|
776
|
+
# --- Transform scales ---
|
|
777
|
+
for i in range(len(data)):
|
|
778
|
+
if data[i] is not None and not data[i].empty:
|
|
779
|
+
data[i] = scales.transform_df(data[i])
|
|
780
|
+
|
|
781
|
+
# --- Train and map positions ---
|
|
782
|
+
scale_x = scales.get_scales("x")
|
|
783
|
+
scale_y = scales.get_scales("y")
|
|
784
|
+
layout.train_position(data, scale_x, scale_y)
|
|
785
|
+
data = layout.map_position(data)
|
|
786
|
+
|
|
787
|
+
# --- Compute statistics ---
|
|
788
|
+
data = _h(plot, "before", S.COMPUTE_STAT, data)
|
|
789
|
+
data = by_layer(lambda l, d: l.compute_statistic(d, layout), layers, data, "computing stat")
|
|
790
|
+
data = _h(plot, "after", S.COMPUTE_STAT, data)
|
|
791
|
+
|
|
792
|
+
# --- Map statistics ---
|
|
793
|
+
data = _h(plot, "before", S.MAP_STAT, data)
|
|
794
|
+
data = by_layer(lambda l, d: l.map_statistic(d, plot), layers, data, "mapping stat to aesthetics")
|
|
795
|
+
data = _h(plot, "after", S.MAP_STAT, data)
|
|
796
|
+
|
|
797
|
+
# R (layer.R:map_statistic, plot-build.R:83): after stats map new
|
|
798
|
+
# aesthetics into data (e.g. ``fill = after_stat(count)`` from
|
|
799
|
+
# StatBinhex), scales for those new columns must be registered
|
|
800
|
+
# with the plot. Without this step ``geom_hex`` ends up with a
|
|
801
|
+
# ``fill`` column of numeric counts but no scale_fill, so the
|
|
802
|
+
# colourbar legend never appears and the hex cells render
|
|
803
|
+
# without the gradient.
|
|
804
|
+
for i in range(len(data)):
|
|
805
|
+
if data[i] is not None and not data[i].empty:
|
|
806
|
+
scales.add_defaults(data[i], plot.plot_env)
|
|
807
|
+
|
|
808
|
+
# --- Add missing scales ---
|
|
809
|
+
scales.add_missing(["x", "y"], plot.plot_env)
|
|
810
|
+
|
|
811
|
+
# --- Compute geom 1 ---
|
|
812
|
+
data = _h(plot, "before", S.COMPUTE_GEOM_1, data)
|
|
813
|
+
data = by_layer(lambda l, d: l.compute_geom_1(d), layers, data, "setting up geom")
|
|
814
|
+
data = _h(plot, "after", S.COMPUTE_GEOM_1, data)
|
|
815
|
+
|
|
816
|
+
# --- Compute position ---
|
|
817
|
+
data = _h(plot, "before", S.COMPUTE_POSITION, data)
|
|
818
|
+
data = by_layer(lambda l, d: l.compute_position(d, layout), layers, data, "computing position")
|
|
819
|
+
data = _h(plot, "after", S.COMPUTE_POSITION, data)
|
|
820
|
+
|
|
821
|
+
# --- Reset and retrain position scales ---
|
|
822
|
+
scale_x = scales.get_scales("x")
|
|
823
|
+
scale_y = scales.get_scales("y")
|
|
824
|
+
layout.reset_scales()
|
|
825
|
+
layout.train_position(data, scale_x, scale_y)
|
|
826
|
+
layout.setup_panel_params()
|
|
827
|
+
data = layout.map_position(data)
|
|
828
|
+
|
|
829
|
+
# --- Setup panel guides ---
|
|
830
|
+
layout.setup_panel_guides(plot.guides, plot.layers)
|
|
831
|
+
|
|
832
|
+
# --- Complete theme ---
|
|
833
|
+
# R: plot@theme <- plot_theme(plot) (plot-build.R:107)
|
|
834
|
+
# No try/except: R lets theme errors surface; silently discarding them
|
|
835
|
+
# leaves ``plot.theme`` as an incomplete object and every downstream
|
|
836
|
+
# ``calc_element`` then returns ``None`` (no bg, no grid, no titles).
|
|
837
|
+
plot.theme = complete_theme(plot.theme)
|
|
838
|
+
|
|
839
|
+
# --- Train non-position scales and guides ---
|
|
840
|
+
npscales = scales.non_position_scales()
|
|
841
|
+
if npscales.n() > 0:
|
|
842
|
+
if hasattr(npscales, "set_palettes"):
|
|
843
|
+
npscales.set_palettes(plot.theme)
|
|
844
|
+
for d in data:
|
|
845
|
+
if d is not None:
|
|
846
|
+
npscales.train_df(d)
|
|
847
|
+
if plot.guides is not None and hasattr(plot.guides, "build"):
|
|
848
|
+
plot.guides = plot.guides.build(npscales, plot.layers, plot.labels, data, plot.theme)
|
|
849
|
+
for i in range(len(data)):
|
|
850
|
+
if data[i] is not None:
|
|
851
|
+
data[i] = npscales.map_df(data[i])
|
|
852
|
+
else:
|
|
853
|
+
if plot.guides is not None and hasattr(plot.guides, "get_custom"):
|
|
854
|
+
plot.guides = plot.guides.get_custom()
|
|
855
|
+
|
|
856
|
+
# --- Compute geom 2 ---
|
|
857
|
+
data = _h(plot, "before", S.COMPUTE_GEOM_2, data)
|
|
858
|
+
data = by_layer(lambda l, d: l.compute_geom_2(d, theme=plot.theme), layers, data, "setting up geom aesthetics")
|
|
859
|
+
data = _h(plot, "after", S.COMPUTE_GEOM_2, data)
|
|
860
|
+
|
|
861
|
+
# --- Finish statistics ---
|
|
862
|
+
data = _h(plot, "before", S.FINISH_STAT, data)
|
|
863
|
+
data = by_layer(lambda l, d: l.finish_statistics(d), layers, data, "finishing layer stat")
|
|
864
|
+
data = _h(plot, "after", S.FINISH_STAT, data)
|
|
865
|
+
|
|
866
|
+
# --- Finish data ---
|
|
867
|
+
data = _h(plot, "before", S.FINISH_DATA, data)
|
|
868
|
+
data = layout.finish_data(data)
|
|
869
|
+
data = _h(plot, "after", S.FINISH_DATA, data)
|
|
870
|
+
|
|
871
|
+
# --- Consolidate alt-text ---
|
|
872
|
+
plot.labels["alt"] = get_alt_text(plot)
|
|
873
|
+
|
|
874
|
+
return BuiltGGPlot(data=data, layout=layout, plot=plot)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _setup_plot_labels(
|
|
878
|
+
plot: GGPlot,
|
|
879
|
+
layers: List[Any],
|
|
880
|
+
data: List[pd.DataFrame],
|
|
881
|
+
) -> None:
|
|
882
|
+
"""Collect default labels from layer mappings and merge with plot labels.
|
|
883
|
+
|
|
884
|
+
Parameters
|
|
885
|
+
----------
|
|
886
|
+
plot : GGPlot
|
|
887
|
+
The plot (modified in-place).
|
|
888
|
+
layers : list
|
|
889
|
+
Plot layers.
|
|
890
|
+
data : list of DataFrame
|
|
891
|
+
Computed data per layer.
|
|
892
|
+
"""
|
|
893
|
+
auto_labels: Dict[str, str] = {}
|
|
894
|
+
for i, layer in enumerate(layers):
|
|
895
|
+
mapping = getattr(layer, "computed_mapping", None)
|
|
896
|
+
if mapping is None:
|
|
897
|
+
mapping = getattr(layer, "mapping", None)
|
|
898
|
+
if mapping is None:
|
|
899
|
+
continue
|
|
900
|
+
layer_labels = make_labels(mapping)
|
|
901
|
+
# Default labels from stat
|
|
902
|
+
if hasattr(layer, "stat") and hasattr(layer.stat, "default_aes"):
|
|
903
|
+
stat_labels = make_labels(layer.stat.default_aes)
|
|
904
|
+
for k, v in stat_labels.items():
|
|
905
|
+
if k not in layer_labels:
|
|
906
|
+
layer_labels[k] = v
|
|
907
|
+
# Merge: first layer wins
|
|
908
|
+
for k, v in layer_labels.items():
|
|
909
|
+
if k not in auto_labels:
|
|
910
|
+
auto_labels[k] = v
|
|
911
|
+
|
|
912
|
+
# Merge: user labels override auto labels
|
|
913
|
+
merged = Labels(auto_labels)
|
|
914
|
+
merged.update(plot.labels)
|
|
915
|
+
plot.labels = merged
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
# ---------------------------------------------------------------------------
|
|
919
|
+
# Rendering functions — delegated to plot_render.py
|
|
920
|
+
# (mirrors R's separation of plot-build.R / plot-render.R)
|
|
921
|
+
# ---------------------------------------------------------------------------
|
|
922
|
+
|
|
923
|
+
from ggplot2_py.plot_render import ( # noqa: E402
|
|
924
|
+
ggplot_gtable,
|
|
925
|
+
ggplotGrob,
|
|
926
|
+
_safe_colour,
|
|
927
|
+
_table_add_legends,
|
|
928
|
+
_table_add_titles,
|
|
929
|
+
find_panel,
|
|
930
|
+
panel_rows,
|
|
931
|
+
panel_cols,
|
|
932
|
+
print_plot,
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
# ---------------------------------------------------------------------------
|
|
936
|
+
|
|
937
|
+
def ggplot_add(obj: Any, plot: GGPlot, object_name: str = "") -> GGPlot:
|
|
938
|
+
"""Add an object to a ggplot (generic dispatch).
|
|
939
|
+
|
|
940
|
+
This is the Python equivalent of R's ``ggplot_add()`` S3 generic.
|
|
941
|
+
It dispatches based on the type of *obj* via :func:`update_ggplot`.
|
|
942
|
+
|
|
943
|
+
Parameters
|
|
944
|
+
----------
|
|
945
|
+
obj : object
|
|
946
|
+
The component to add.
|
|
947
|
+
plot : GGPlot
|
|
948
|
+
The plot to modify.
|
|
949
|
+
object_name : str, optional
|
|
950
|
+
Name of the object (for error messages).
|
|
951
|
+
|
|
952
|
+
Returns
|
|
953
|
+
-------
|
|
954
|
+
GGPlot
|
|
955
|
+
The modified plot.
|
|
956
|
+
"""
|
|
957
|
+
return update_ggplot(obj, plot, object_name)
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
# ---------------------------------------------------------------------------
|
|
961
|
+
# update_ggplot — singledispatch generic (R ref: plot-construction.R:133,
|
|
962
|
+
# ``update_ggplot <- S7::new_generic("update_ggplot", c("object","plot"))``).
|
|
963
|
+
#
|
|
964
|
+
# Extension packages can register new types via:
|
|
965
|
+
#
|
|
966
|
+
# from ggplot2_py.plot import update_ggplot
|
|
967
|
+
# @update_ggplot.register(MyType)
|
|
968
|
+
# def _add_my_type(obj, plot, object_name=""):
|
|
969
|
+
# ...
|
|
970
|
+
# return plot
|
|
971
|
+
# ---------------------------------------------------------------------------
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
@singledispatch
|
|
975
|
+
def update_ggplot(obj: Any, plot: GGPlot, object_name: str = "") -> GGPlot:
|
|
976
|
+
"""Add *obj* to *plot*. Open generic — register new types with
|
|
977
|
+
``@update_ggplot.register(YourType)``."""
|
|
978
|
+
# Fallback: try some duck-typed checks for types that can't easily be
|
|
979
|
+
# registered at import time due to circular imports.
|
|
980
|
+
# --- Guides (duck-type: has _is_guides flag) ---
|
|
981
|
+
if getattr(obj, "_is_guides", False):
|
|
982
|
+
if plot.guides is not None and hasattr(plot.guides, "add"):
|
|
983
|
+
plot.guides.add(obj)
|
|
984
|
+
else:
|
|
985
|
+
plot.guides = obj
|
|
986
|
+
return plot
|
|
987
|
+
# --- GGProto (error) ---
|
|
988
|
+
if is_ggproto(obj):
|
|
989
|
+
cli_abort(
|
|
990
|
+
"Cannot add ggproto objects together. "
|
|
991
|
+
"Did you forget to add this object to a ggplot object?"
|
|
992
|
+
)
|
|
993
|
+
# --- Callable (error with hint) ---
|
|
994
|
+
if callable(obj):
|
|
995
|
+
name = object_name or getattr(obj, "__name__", "object")
|
|
996
|
+
cli_abort(
|
|
997
|
+
f"Cannot add `{name}` to a ggplot object. "
|
|
998
|
+
f"Did you forget to add parentheses, as in `{name}()`?"
|
|
999
|
+
)
|
|
1000
|
+
cli_abort(
|
|
1001
|
+
f"Cannot add `{object_name or type(obj).__name__}` to a ggplot object."
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
@update_ggplot.register(type(None))
|
|
1006
|
+
def _update_none(obj, plot, object_name=""):
|
|
1007
|
+
return plot
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
@update_ggplot.register(list)
|
|
1011
|
+
def _update_list(obj, plot, object_name=""):
|
|
1012
|
+
for item in obj:
|
|
1013
|
+
plot = ggplot_add(item, plot, object_name)
|
|
1014
|
+
return plot
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
@update_ggplot.register(pd.DataFrame)
|
|
1018
|
+
def _update_dataframe(obj, plot, object_name=""):
|
|
1019
|
+
plot.data = obj
|
|
1020
|
+
return plot
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
@update_ggplot.register(Mapping)
|
|
1024
|
+
def _update_mapping(obj, plot, object_name=""):
|
|
1025
|
+
merged_mapping = aes(**{**plot.mapping, **obj})
|
|
1026
|
+
plot.mapping = merged_mapping
|
|
1027
|
+
return plot
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
@update_ggplot.register(Labels)
|
|
1031
|
+
def _update_labels(obj, plot, object_name=""):
|
|
1032
|
+
merged = Labels(plot.labels)
|
|
1033
|
+
merged.update(obj)
|
|
1034
|
+
plot.labels = merged
|
|
1035
|
+
return plot
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
# Registrations for types from other modules are deferred to avoid
|
|
1039
|
+
# circular imports. They are registered via _register_update_ggplot_types()
|
|
1040
|
+
# called at the bottom of this module (after the lazy imports block).
|
|
1041
|
+
|
|
1042
|
+
def _register_update_ggplot_types():
|
|
1043
|
+
"""Register update_ggplot handlers for types that require lazy imports."""
|
|
1044
|
+
from ggplot2_py.layer import Layer
|
|
1045
|
+
from ggplot2_py.scale import Scale
|
|
1046
|
+
from ggplot2_py.coord import Coord
|
|
1047
|
+
from ggplot2_py.facet import Facet
|
|
1048
|
+
from ggplot2_py.theme import Theme, add_theme
|
|
1049
|
+
|
|
1050
|
+
@update_ggplot.register(Layer)
|
|
1051
|
+
def _update_layer(obj, plot, object_name=""):
|
|
1052
|
+
plot.layers.append(obj)
|
|
1053
|
+
return plot
|
|
1054
|
+
|
|
1055
|
+
@update_ggplot.register(Scale)
|
|
1056
|
+
def _update_scale(obj, plot, object_name=""):
|
|
1057
|
+
plot.scales.add(obj)
|
|
1058
|
+
return plot
|
|
1059
|
+
|
|
1060
|
+
@update_ggplot.register(Coord)
|
|
1061
|
+
def _update_coord(obj, plot, object_name=""):
|
|
1062
|
+
if (
|
|
1063
|
+
not getattr(plot.coordinates, "default", True)
|
|
1064
|
+
and getattr(obj, "default", False)
|
|
1065
|
+
):
|
|
1066
|
+
return plot
|
|
1067
|
+
if not getattr(plot.coordinates, "default", True):
|
|
1068
|
+
cli_inform(
|
|
1069
|
+
"Coordinate system already present. "
|
|
1070
|
+
"Adding new coordinate system, which will replace the existing one."
|
|
1071
|
+
)
|
|
1072
|
+
plot.coordinates = obj
|
|
1073
|
+
return plot
|
|
1074
|
+
|
|
1075
|
+
@update_ggplot.register(Facet)
|
|
1076
|
+
def _update_facet(obj, plot, object_name=""):
|
|
1077
|
+
plot.facet = obj
|
|
1078
|
+
return plot
|
|
1079
|
+
|
|
1080
|
+
@update_ggplot.register(Theme)
|
|
1081
|
+
def _update_theme(obj, plot, object_name=""):
|
|
1082
|
+
plot.theme = add_theme(plot.theme, obj)
|
|
1083
|
+
return plot
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
# Perform deferred registrations.
|
|
1087
|
+
_register_update_ggplot_types()
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def add_gg(e1: Any, e2: Any) -> Any:
|
|
1091
|
+
"""Implement the ``+`` operator for gg objects.
|
|
1092
|
+
|
|
1093
|
+
Parameters
|
|
1094
|
+
----------
|
|
1095
|
+
e1 : GGPlot or Theme
|
|
1096
|
+
Left-hand side.
|
|
1097
|
+
e2 : object
|
|
1098
|
+
Right-hand side component.
|
|
1099
|
+
|
|
1100
|
+
Returns
|
|
1101
|
+
-------
|
|
1102
|
+
GGPlot or Theme
|
|
1103
|
+
"""
|
|
1104
|
+
from ggplot2_py.theme import is_theme, add_theme
|
|
1105
|
+
|
|
1106
|
+
if is_theme(e1):
|
|
1107
|
+
return add_theme(e1, e2)
|
|
1108
|
+
elif is_ggplot(e1):
|
|
1109
|
+
return e1 + e2
|
|
1110
|
+
elif is_ggproto(e1):
|
|
1111
|
+
cli_abort(
|
|
1112
|
+
"Cannot add ggproto objects together. "
|
|
1113
|
+
"Did you forget to add this object to a ggplot object?"
|
|
1114
|
+
)
|
|
1115
|
+
else:
|
|
1116
|
+
cli_abort(f"Cannot use `+` with {type(e1).__name__}.")
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
# ---------------------------------------------------------------------------
|
|
1120
|
+
# Alt-text
|
|
1121
|
+
# ---------------------------------------------------------------------------
|
|
1122
|
+
|
|
1123
|
+
def get_alt_text(plot: Any) -> str:
|
|
1124
|
+
"""Extract alt-text from a plot.
|
|
1125
|
+
|
|
1126
|
+
Parameters
|
|
1127
|
+
----------
|
|
1128
|
+
plot : GGPlot or BuiltGGPlot or gtable
|
|
1129
|
+
The plot or built plot.
|
|
1130
|
+
|
|
1131
|
+
Returns
|
|
1132
|
+
-------
|
|
1133
|
+
str
|
|
1134
|
+
Alt-text string, or empty string if none is set.
|
|
1135
|
+
"""
|
|
1136
|
+
if isinstance(plot, BuiltGGPlot):
|
|
1137
|
+
alt = plot.plot.labels.get("alt", "")
|
|
1138
|
+
if callable(alt):
|
|
1139
|
+
return alt(plot.plot)
|
|
1140
|
+
return alt or ""
|
|
1141
|
+
|
|
1142
|
+
if isinstance(plot, GGPlot):
|
|
1143
|
+
alt = plot.labels.get("alt", "")
|
|
1144
|
+
if callable(alt):
|
|
1145
|
+
# Would need to build first; just return empty
|
|
1146
|
+
return ""
|
|
1147
|
+
return alt or ""
|
|
1148
|
+
|
|
1149
|
+
# gtable
|
|
1150
|
+
if hasattr(plot, "_alt_label"):
|
|
1151
|
+
return plot._alt_label or ""
|
|
1152
|
+
|
|
1153
|
+
return ""
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
# ---------------------------------------------------------------------------
|
|
1157
|
+
# Introspection helpers
|
|
1158
|
+
# ---------------------------------------------------------------------------
|
|
1159
|
+
|
|
1160
|
+
def get_layer_data(
|
|
1161
|
+
plot: Any = None,
|
|
1162
|
+
i: int = 1,
|
|
1163
|
+
) -> pd.DataFrame:
|
|
1164
|
+
"""Return the computed data for a given layer.
|
|
1165
|
+
|
|
1166
|
+
Parameters
|
|
1167
|
+
----------
|
|
1168
|
+
plot : GGPlot or None
|
|
1169
|
+
Plot to inspect. ``None`` uses :func:`get_last_plot`.
|
|
1170
|
+
i : int
|
|
1171
|
+
Layer index (1-based).
|
|
1172
|
+
|
|
1173
|
+
Returns
|
|
1174
|
+
-------
|
|
1175
|
+
DataFrame
|
|
1176
|
+
"""
|
|
1177
|
+
if plot is None:
|
|
1178
|
+
plot = get_last_plot()
|
|
1179
|
+
built = ggplot_build(plot)
|
|
1180
|
+
idx = i - 1 # Convert to 0-based
|
|
1181
|
+
if idx < 0 or idx >= len(built.data):
|
|
1182
|
+
cli_abort(f"Layer index {i} out of range (plot has {len(built.data)} layers).")
|
|
1183
|
+
return built.data[idx]
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
layer_data = get_layer_data
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def get_layer_grob(
|
|
1190
|
+
plot: Any = None,
|
|
1191
|
+
i: int = 1,
|
|
1192
|
+
) -> Any:
|
|
1193
|
+
"""Return the grob for a given layer.
|
|
1194
|
+
|
|
1195
|
+
Parameters
|
|
1196
|
+
----------
|
|
1197
|
+
plot : GGPlot or None
|
|
1198
|
+
Plot to inspect.
|
|
1199
|
+
i : int
|
|
1200
|
+
Layer index (1-based).
|
|
1201
|
+
|
|
1202
|
+
Returns
|
|
1203
|
+
-------
|
|
1204
|
+
grob
|
|
1205
|
+
"""
|
|
1206
|
+
if plot is None:
|
|
1207
|
+
plot = get_last_plot()
|
|
1208
|
+
built = ggplot_build(plot)
|
|
1209
|
+
idx = i - 1
|
|
1210
|
+
if idx < 0 or idx >= len(built.data):
|
|
1211
|
+
cli_abort(f"Layer index {i} out of range.")
|
|
1212
|
+
layer = built.plot.layers[idx]
|
|
1213
|
+
if hasattr(layer, "draw_geom"):
|
|
1214
|
+
return layer.draw_geom(built.data[idx], built.layout)
|
|
1215
|
+
return None
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
layer_grob = get_layer_grob
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
def get_panel_scales(
|
|
1222
|
+
plot: Any = None,
|
|
1223
|
+
i: int = 1,
|
|
1224
|
+
j: int = 1,
|
|
1225
|
+
) -> Dict[str, Any]:
|
|
1226
|
+
"""Return position scales for a specific panel.
|
|
1227
|
+
|
|
1228
|
+
Parameters
|
|
1229
|
+
----------
|
|
1230
|
+
plot : GGPlot or None
|
|
1231
|
+
Plot to inspect.
|
|
1232
|
+
i : int
|
|
1233
|
+
Row index (1-based).
|
|
1234
|
+
j : int
|
|
1235
|
+
Column index (1-based).
|
|
1236
|
+
|
|
1237
|
+
Returns
|
|
1238
|
+
-------
|
|
1239
|
+
dict
|
|
1240
|
+
``{"x": Scale, "y": Scale}``
|
|
1241
|
+
"""
|
|
1242
|
+
if plot is None:
|
|
1243
|
+
plot = get_last_plot()
|
|
1244
|
+
built = ggplot_build(plot)
|
|
1245
|
+
layout_df = built.layout.layout
|
|
1246
|
+
sel = layout_df[(layout_df["ROW"] == i) & (layout_df["COL"] == j)]
|
|
1247
|
+
if sel.empty:
|
|
1248
|
+
return {"x": None, "y": None}
|
|
1249
|
+
row = sel.iloc[0]
|
|
1250
|
+
sx_idx = int(row["SCALE_X"]) - 1
|
|
1251
|
+
sy_idx = int(row["SCALE_Y"]) - 1
|
|
1252
|
+
return {
|
|
1253
|
+
"x": built.layout.panel_scales_x[sx_idx] if built.layout.panel_scales_x else None,
|
|
1254
|
+
"y": built.layout.panel_scales_y[sy_idx] if built.layout.panel_scales_y else None,
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
layer_scales = get_panel_scales
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def get_guide_data(
|
|
1262
|
+
plot: Any = None,
|
|
1263
|
+
aesthetic: str = "colour",
|
|
1264
|
+
) -> Any:
|
|
1265
|
+
"""Retrieve guide data for a given aesthetic (stub).
|
|
1266
|
+
|
|
1267
|
+
Parameters
|
|
1268
|
+
----------
|
|
1269
|
+
plot : GGPlot or None
|
|
1270
|
+
Plot to inspect.
|
|
1271
|
+
aesthetic : str
|
|
1272
|
+
Aesthetic name.
|
|
1273
|
+
|
|
1274
|
+
Returns
|
|
1275
|
+
-------
|
|
1276
|
+
object
|
|
1277
|
+
Guide data or ``None``.
|
|
1278
|
+
"""
|
|
1279
|
+
return None
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def get_strip_labels(
|
|
1283
|
+
plot: Any = None,
|
|
1284
|
+
) -> Any:
|
|
1285
|
+
"""Retrieve strip labels from a faceted plot (stub).
|
|
1286
|
+
|
|
1287
|
+
Parameters
|
|
1288
|
+
----------
|
|
1289
|
+
plot : GGPlot or None
|
|
1290
|
+
Plot to inspect.
|
|
1291
|
+
|
|
1292
|
+
Returns
|
|
1293
|
+
-------
|
|
1294
|
+
dict or None
|
|
1295
|
+
"""
|
|
1296
|
+
return None
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
def get_labs(plot: Any = None) -> Labels:
|
|
1300
|
+
"""Retrieve resolved labels from a plot.
|
|
1301
|
+
|
|
1302
|
+
Parameters
|
|
1303
|
+
----------
|
|
1304
|
+
plot : GGPlot or None
|
|
1305
|
+
Plot to inspect.
|
|
1306
|
+
|
|
1307
|
+
Returns
|
|
1308
|
+
-------
|
|
1309
|
+
Labels
|
|
1310
|
+
"""
|
|
1311
|
+
from ggplot2_py.labels import get_labs as _get_labs
|
|
1312
|
+
return _get_labs(plot)
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
# ---------------------------------------------------------------------------
|
|
1316
|
+
# Summary / introspection
|
|
1317
|
+
# ---------------------------------------------------------------------------
|
|
1318
|
+
|
|
1319
|
+
def summarise_plot(plot: GGPlot) -> Dict[str, Any]:
|
|
1320
|
+
"""Summarise a plot's main components.
|
|
1321
|
+
|
|
1322
|
+
Parameters
|
|
1323
|
+
----------
|
|
1324
|
+
plot : GGPlot
|
|
1325
|
+
Plot to summarise.
|
|
1326
|
+
|
|
1327
|
+
Returns
|
|
1328
|
+
-------
|
|
1329
|
+
dict
|
|
1330
|
+
"""
|
|
1331
|
+
return {
|
|
1332
|
+
"data": type(plot.data).__name__,
|
|
1333
|
+
"mapping": dict(plot.mapping) if plot.mapping else {},
|
|
1334
|
+
"n_layers": len(plot.layers),
|
|
1335
|
+
"coord": type(plot.coordinates).__name__ if plot.coordinates else None,
|
|
1336
|
+
"facet": type(plot.facet).__name__ if plot.facet else None,
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
def summarise_coord(plot: GGPlot) -> Dict[str, Any]:
|
|
1341
|
+
"""Summarise the coordinate system.
|
|
1342
|
+
|
|
1343
|
+
Parameters
|
|
1344
|
+
----------
|
|
1345
|
+
plot : GGPlot
|
|
1346
|
+
|
|
1347
|
+
Returns
|
|
1348
|
+
-------
|
|
1349
|
+
dict
|
|
1350
|
+
"""
|
|
1351
|
+
coord = plot.coordinates
|
|
1352
|
+
if coord is None:
|
|
1353
|
+
return {}
|
|
1354
|
+
return {
|
|
1355
|
+
"class": type(coord).__name__,
|
|
1356
|
+
"default": getattr(coord, "default", None),
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
def summarise_layers(plot: GGPlot) -> List[Dict[str, Any]]:
|
|
1361
|
+
"""Summarise each layer.
|
|
1362
|
+
|
|
1363
|
+
Parameters
|
|
1364
|
+
----------
|
|
1365
|
+
plot : GGPlot
|
|
1366
|
+
|
|
1367
|
+
Returns
|
|
1368
|
+
-------
|
|
1369
|
+
list of dict
|
|
1370
|
+
"""
|
|
1371
|
+
result = []
|
|
1372
|
+
for layer in plot.layers:
|
|
1373
|
+
info: Dict[str, Any] = {}
|
|
1374
|
+
if hasattr(layer, "geom"):
|
|
1375
|
+
info["geom"] = type(layer.geom).__name__ if not isinstance(layer.geom, str) else layer.geom
|
|
1376
|
+
if hasattr(layer, "stat"):
|
|
1377
|
+
info["stat"] = type(layer.stat).__name__ if not isinstance(layer.stat, str) else layer.stat
|
|
1378
|
+
if hasattr(layer, "mapping") and layer.mapping:
|
|
1379
|
+
info["mapping"] = dict(layer.mapping)
|
|
1380
|
+
result.append(info)
|
|
1381
|
+
return result
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def summarise_layout(plot: GGPlot) -> Dict[str, Any]:
|
|
1385
|
+
"""Summarise the layout / faceting.
|
|
1386
|
+
|
|
1387
|
+
Parameters
|
|
1388
|
+
----------
|
|
1389
|
+
plot : GGPlot
|
|
1390
|
+
|
|
1391
|
+
Returns
|
|
1392
|
+
-------
|
|
1393
|
+
dict
|
|
1394
|
+
"""
|
|
1395
|
+
facet = plot.facet
|
|
1396
|
+
if facet is None:
|
|
1397
|
+
return {}
|
|
1398
|
+
return {
|
|
1399
|
+
"class": type(facet).__name__,
|
|
1400
|
+
"vars": list(facet.vars()) if hasattr(facet, "vars") else [],
|
|
1401
|
+
}
|