ggplot2-python 4.0.2.9000__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. ggplot2_py/__init__.py +852 -0
  2. ggplot2_py/_compat.py +475 -0
  3. ggplot2_py/_plugins.py +129 -0
  4. ggplot2_py/_utils.py +544 -0
  5. ggplot2_py/aes.py +586 -0
  6. ggplot2_py/annotation.py +540 -0
  7. ggplot2_py/coord.py +2108 -0
  8. ggplot2_py/coords/__init__.py +49 -0
  9. ggplot2_py/datasets.py +265 -0
  10. ggplot2_py/draw_key.py +454 -0
  11. ggplot2_py/facet.py +1456 -0
  12. ggplot2_py/fortify.py +95 -0
  13. ggplot2_py/geom.py +4516 -0
  14. ggplot2_py/geoms/__init__.py +12 -0
  15. ggplot2_py/ggproto.py +279 -0
  16. ggplot2_py/guide.py +2925 -0
  17. ggplot2_py/guide_axis.py +615 -0
  18. ggplot2_py/guide_colourbar.py +657 -0
  19. ggplot2_py/guide_legend.py +1061 -0
  20. ggplot2_py/guides/__init__.py +8 -0
  21. ggplot2_py/labeller.py +296 -0
  22. ggplot2_py/labels.py +309 -0
  23. ggplot2_py/layer.py +954 -0
  24. ggplot2_py/layout.py +754 -0
  25. ggplot2_py/limits.py +314 -0
  26. ggplot2_py/plot.py +1401 -0
  27. ggplot2_py/plot_render.py +866 -0
  28. ggplot2_py/position.py +1269 -0
  29. ggplot2_py/protocols.py +171 -0
  30. ggplot2_py/py.typed +0 -0
  31. ggplot2_py/qplot.py +233 -0
  32. ggplot2_py/resources/diamonds.csv +53941 -0
  33. ggplot2_py/resources/economics.csv +575 -0
  34. ggplot2_py/resources/economics_long.csv +2871 -0
  35. ggplot2_py/resources/faithfuld.csv +5626 -0
  36. ggplot2_py/resources/luv_colours.csv +658 -0
  37. ggplot2_py/resources/midwest.csv +438 -0
  38. ggplot2_py/resources/mpg.csv +235 -0
  39. ggplot2_py/resources/msleep.csv +84 -0
  40. ggplot2_py/resources/presidential.csv +13 -0
  41. ggplot2_py/resources/seals.csv +1156 -0
  42. ggplot2_py/resources/txhousing.csv +8603 -0
  43. ggplot2_py/save.py +316 -0
  44. ggplot2_py/scale.py +2727 -0
  45. ggplot2_py/scales/__init__.py +4252 -0
  46. ggplot2_py/stat.py +6071 -0
  47. ggplot2_py/stats/__init__.py +9 -0
  48. ggplot2_py/theme.py +490 -0
  49. ggplot2_py/theme_defaults.py +1350 -0
  50. ggplot2_py/theme_elements.py +2052 -0
  51. ggplot2_python-4.0.2.9000.dist-info/METADATA +179 -0
  52. ggplot2_python-4.0.2.9000.dist-info/RECORD +54 -0
  53. ggplot2_python-4.0.2.9000.dist-info/WHEEL +4 -0
  54. ggplot2_python-4.0.2.9000.dist-info/licenses/LICENSE +3 -0
ggplot2_py/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
+ }