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/layout.py ADDED
@@ -0,0 +1,754 @@
1
+ """
2
+ Layout: coordinate system + faceting + panel-scale management.
3
+
4
+ The :class:`Layout` class is the internal engine that connects facets,
5
+ coordinates, and per-panel scales during the build and render phases of
6
+ a ggplot.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Callable, Dict, List, Optional, Tuple, Union
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+
16
+ from ggplot2_py._compat import Waiver, is_waiver, waiver, cli_abort
17
+ from ggplot2_py.ggproto import GGProto, ggproto
18
+ from ggplot2_py._utils import data_frame
19
+
20
+ __all__ = ["Layout"]
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Helper
25
+ # ---------------------------------------------------------------------------
26
+
27
+ def _scale_apply(
28
+ data: pd.DataFrame,
29
+ vars_: List[str],
30
+ method: str,
31
+ scale_id: pd.Series,
32
+ scales: List[Any],
33
+ ) -> Dict[str, Any]:
34
+ """Apply a scale method to columns split by panel scale index.
35
+
36
+ Parameters
37
+ ----------
38
+ data : DataFrame
39
+ Layer data.
40
+ vars_ : list of str
41
+ Aesthetic column names to process.
42
+ method : str
43
+ Scale method name (e.g. ``"map"``).
44
+ scale_id : array-like
45
+ Per-row scale index (from ``layout.SCALE_X[match_id]``).
46
+ scales : list of Scale
47
+ The panel scales list.
48
+
49
+ Returns
50
+ -------
51
+ dict
52
+ Column-name -> mapped-values mapping.
53
+ """
54
+ if len(vars_) == 0 or data.shape[0] == 0:
55
+ return {}
56
+
57
+ result: Dict[str, Any] = {}
58
+ for var in vars_:
59
+ pieces: List[Any] = []
60
+ indices: List[np.ndarray] = []
61
+ for i, sc in enumerate(scales):
62
+ mask = scale_id == (i + 1) # scale indices are 1-based
63
+ idx = np.where(mask)[0]
64
+ if len(idx) == 0:
65
+ continue
66
+ chunk = data[var].iloc[idx]
67
+ mapped = getattr(sc, method)(chunk)
68
+ pieces.append(mapped)
69
+ indices.append(idx)
70
+ if pieces:
71
+ # Reconstruct in original order
72
+ out = pd.Series(np.nan, index=data.index, dtype=object)
73
+ for idx_arr, piece in zip(indices, pieces):
74
+ if isinstance(piece, pd.Series):
75
+ out.iloc[idx_arr] = piece.values
76
+ elif isinstance(piece, np.ndarray):
77
+ out.iloc[idx_arr] = piece
78
+ elif isinstance(piece, (list, tuple)):
79
+ out.iloc[idx_arr] = piece
80
+ else:
81
+ out.iloc[idx_arr] = piece
82
+ # Try to convert to numeric if possible
83
+ try:
84
+ result[var] = pd.to_numeric(out, errors="raise")
85
+ except (ValueError, TypeError):
86
+ result[var] = out
87
+ else:
88
+ result[var] = data[var].copy()
89
+ return result
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Layout class
94
+ # ---------------------------------------------------------------------------
95
+
96
+ class Layout(GGProto):
97
+ """Panel layout manager.
98
+
99
+ The Layout manages panel creation and scale management during the
100
+ build (``ggplot_build``) and render (``ggplot_gtable``) phases.
101
+
102
+ Attributes
103
+ ----------
104
+ coord : Coord
105
+ The coordinate system.
106
+ coord_params : dict
107
+ Parameters populated by ``Coord.setup_params()``.
108
+ facet : Facet
109
+ The faceting specification.
110
+ facet_params : dict
111
+ Parameters populated by ``Facet.setup_params()``.
112
+ layout : DataFrame
113
+ One row per panel with columns ``PANEL``, ``ROW``, ``COL``,
114
+ ``SCALE_X``, ``SCALE_Y``, and possibly faceting variables.
115
+ panel_scales_x : list of Scale
116
+ Per-panel x scales (indexed by ``SCALE_X``).
117
+ panel_scales_y : list of Scale
118
+ Per-panel y scales (indexed by ``SCALE_Y``).
119
+ panel_params : list of dict
120
+ Per-panel coordinate parameters.
121
+ """
122
+
123
+ _class_name = "Layout"
124
+
125
+ coord: Any = None
126
+ coord_params: Dict[str, Any] = {}
127
+ facet: Any = None
128
+ facet_params: Dict[str, Any] = {}
129
+ layout: Optional[pd.DataFrame] = None
130
+ panel_scales_x: Optional[List[Any]] = None
131
+ panel_scales_y: Optional[List[Any]] = None
132
+ panel_params: Optional[List[Dict[str, Any]]] = None
133
+
134
+ # ------------------------------------------------------------------
135
+ # ggplot_build phase
136
+ # ------------------------------------------------------------------
137
+
138
+ def setup(
139
+ self,
140
+ data: List[pd.DataFrame],
141
+ plot_data: pd.DataFrame = None,
142
+ plot_env: Any = None,
143
+ ) -> List[pd.DataFrame]:
144
+ """Initialise facet layout and assign panels to data.
145
+
146
+ Parameters
147
+ ----------
148
+ data : list of DataFrame
149
+ Layer data (one DataFrame per layer).
150
+ plot_data : DataFrame, optional
151
+ The plot-level default data.
152
+ plot_env : object, optional
153
+ The plot environment (unused in Python).
154
+
155
+ Returns
156
+ -------
157
+ list of DataFrame
158
+ Layer data with ``PANEL`` column assigned.
159
+ """
160
+ if plot_data is None:
161
+ plot_data = pd.DataFrame()
162
+
163
+ all_data = [plot_data] + list(data)
164
+
165
+ # Setup facet
166
+ if hasattr(self.facet, "setup_params"):
167
+ self.facet_params = self.facet.setup_params(
168
+ all_data, getattr(self.facet, "params", {})
169
+ )
170
+ else:
171
+ self.facet_params = getattr(self.facet, "params", {})
172
+
173
+ if plot_env is not None:
174
+ self.facet_params["plot_env"] = plot_env
175
+
176
+ if hasattr(self.facet, "setup_data"):
177
+ all_data = self.facet.setup_data(all_data, self.facet_params)
178
+
179
+ # Setup coord
180
+ if hasattr(self.coord, "setup_params"):
181
+ self.coord_params = self.coord.setup_params(all_data)
182
+ else:
183
+ self.coord_params = {}
184
+
185
+ if hasattr(self.coord, "setup_data"):
186
+ all_data = self.coord.setup_data(all_data, self.coord_params)
187
+
188
+ # Generate panel layout
189
+ if hasattr(self.facet, "compute_layout"):
190
+ self.layout = self.facet.compute_layout(all_data, self.facet_params)
191
+ else:
192
+ self.layout = pd.DataFrame({
193
+ "PANEL": pd.Categorical([1]),
194
+ "ROW": [1],
195
+ "COL": [1],
196
+ "SCALE_X": [1],
197
+ "SCALE_Y": [1],
198
+ })
199
+
200
+ if hasattr(self.coord, "setup_layout"):
201
+ self.layout = self.coord.setup_layout(self.layout, self.coord_params)
202
+
203
+ # Add COORD column if not present (used for deduplicating panel_params)
204
+ if "COORD" not in self.layout.columns:
205
+ # Default: unique combination of SCALE_X and SCALE_Y
206
+ self.layout["COORD"] = (
207
+ self.layout["SCALE_X"].astype(str) + "_" +
208
+ self.layout["SCALE_Y"].astype(str)
209
+ )
210
+
211
+ # Map data to panels
212
+ result = []
213
+ for layer_data in all_data[1:]: # skip plot_data (index 0)
214
+ if hasattr(self.facet, "map_data"):
215
+ mapped = self.facet.map_data(
216
+ layer_data,
217
+ layout=self.layout,
218
+ params=self.facet_params,
219
+ )
220
+ result.append(mapped)
221
+ else:
222
+ # Default: assign all rows to panel 1
223
+ ld = layer_data.copy()
224
+ if "PANEL" not in ld.columns:
225
+ ld["PANEL"] = pd.Categorical(
226
+ [1] * len(ld),
227
+ categories=self.layout["PANEL"].cat.categories
228
+ if hasattr(self.layout["PANEL"], "cat") else [1],
229
+ )
230
+ result.append(ld)
231
+ return result
232
+
233
+ def train_position(
234
+ self,
235
+ data: List[pd.DataFrame],
236
+ x_scale: Any,
237
+ y_scale: Any,
238
+ ) -> None:
239
+ """Train position scales for each panel.
240
+
241
+ Parameters
242
+ ----------
243
+ data : list of DataFrame
244
+ Layer data.
245
+ x_scale, y_scale : Scale
246
+ Prototype position scales.
247
+ """
248
+ layout = self.layout
249
+
250
+ # Initialise scales if needed
251
+ if self.panel_scales_x is None and x_scale is not None:
252
+ if hasattr(self.facet, "init_scales"):
253
+ res = self.facet.init_scales(
254
+ layout, x_scale=x_scale, params=self.facet_params
255
+ )
256
+ self.panel_scales_x = res.get("x", [x_scale.clone()])
257
+ else:
258
+ n_x = int(layout["SCALE_X"].max()) if len(layout) > 0 else 1
259
+ self.panel_scales_x = [x_scale.clone() for _ in range(n_x)]
260
+
261
+ if self.panel_scales_y is None and y_scale is not None:
262
+ if hasattr(self.facet, "init_scales"):
263
+ res = self.facet.init_scales(
264
+ layout, y_scale=y_scale, params=self.facet_params
265
+ )
266
+ self.panel_scales_y = res.get("y", [y_scale.clone()])
267
+ else:
268
+ n_y = int(layout["SCALE_Y"].max()) if len(layout) > 0 else 1
269
+ self.panel_scales_y = [y_scale.clone() for _ in range(n_y)]
270
+
271
+ # Train scales
272
+ if hasattr(self.facet, "train_scales"):
273
+ self.facet.train_scales(
274
+ self.panel_scales_x,
275
+ self.panel_scales_y,
276
+ layout,
277
+ data,
278
+ self.facet_params,
279
+ )
280
+ else:
281
+ # Default training: train each scale on matching panel data
282
+ for layer_data in data:
283
+ if layer_data is None or layer_data.empty:
284
+ continue
285
+ if "PANEL" not in layer_data.columns:
286
+ continue
287
+ for _, row in layout.iterrows():
288
+ panel_id = row["PANEL"]
289
+ sx_idx = int(row["SCALE_X"]) - 1
290
+ sy_idx = int(row["SCALE_Y"]) - 1
291
+ mask = layer_data["PANEL"] == panel_id
292
+ panel_data = layer_data.loc[mask]
293
+ if panel_data.empty:
294
+ continue
295
+ if self.panel_scales_x and sx_idx < len(self.panel_scales_x):
296
+ self.panel_scales_x[sx_idx].train_df(panel_data)
297
+ if self.panel_scales_y and sy_idx < len(self.panel_scales_y):
298
+ self.panel_scales_y[sy_idx].train_df(panel_data)
299
+
300
+ def map_position(self, data: List[pd.DataFrame]) -> List[pd.DataFrame]:
301
+ """Map position aesthetics through trained panel scales.
302
+
303
+ Parameters
304
+ ----------
305
+ data : list of DataFrame
306
+ Layer data.
307
+
308
+ Returns
309
+ -------
310
+ list of DataFrame
311
+ Data with mapped position columns.
312
+ """
313
+ layout = self.layout
314
+ result = []
315
+
316
+ for layer_data in data:
317
+ if layer_data is None or layer_data.empty:
318
+ result.append(layer_data)
319
+ continue
320
+
321
+ ld = layer_data.copy()
322
+
323
+ if "PANEL" not in ld.columns:
324
+ result.append(ld)
325
+ continue
326
+
327
+ # Match panels
328
+ panel_vals = ld["PANEL"].values
329
+ # Build match index: for each row, which layout row?
330
+ layout_panels = layout["PANEL"].values
331
+ match_id = np.searchsorted(
332
+ np.sort(layout_panels),
333
+ panel_vals,
334
+ )
335
+ # Safer: use a mapping
336
+ panel_to_idx = {p: i for i, p in enumerate(layout_panels)}
337
+ match_idx = np.array([
338
+ panel_to_idx.get(p, 0) for p in panel_vals
339
+ ])
340
+
341
+ # Map x variables
342
+ if self.panel_scales_x and len(self.panel_scales_x) > 0:
343
+ x_aes = getattr(self.panel_scales_x[0], "aesthetics", ["x"])
344
+ x_vars = [v for v in x_aes if v in ld.columns]
345
+ if x_vars:
346
+ scale_x_ids = layout["SCALE_X"].values[match_idx]
347
+ mapped = _scale_apply(
348
+ ld, x_vars, "map", pd.Series(scale_x_ids),
349
+ self.panel_scales_x,
350
+ )
351
+ for k, v in mapped.items():
352
+ ld[k] = v
353
+
354
+ # Map y variables
355
+ if self.panel_scales_y and len(self.panel_scales_y) > 0:
356
+ y_aes = getattr(self.panel_scales_y[0], "aesthetics", ["y"])
357
+ y_vars = [v for v in y_aes if v in ld.columns]
358
+ if y_vars:
359
+ scale_y_ids = layout["SCALE_Y"].values[match_idx]
360
+ mapped = _scale_apply(
361
+ ld, y_vars, "map", pd.Series(scale_y_ids),
362
+ self.panel_scales_y,
363
+ )
364
+ for k, v in mapped.items():
365
+ ld[k] = v
366
+
367
+ result.append(ld)
368
+ return result
369
+
370
+ def reset_scales(self) -> None:
371
+ """Reset scale ranges (called between stat computation and re-training).
372
+
373
+ If the facet's ``shrink`` attribute is ``False``, this is a no-op.
374
+ """
375
+ if not getattr(self.facet, "shrink", True):
376
+ return
377
+ if self.panel_scales_x:
378
+ for s in self.panel_scales_x:
379
+ if hasattr(s, "reset"):
380
+ s.reset()
381
+ if self.panel_scales_y:
382
+ for s in self.panel_scales_y:
383
+ if hasattr(s, "reset"):
384
+ s.reset()
385
+
386
+ def setup_panel_params(self) -> None:
387
+ """Compute per-panel coordinate parameters.
388
+
389
+ Calls ``Coord.setup_panel_params()`` for each unique x/y scale
390
+ combination.
391
+ """
392
+ if hasattr(self.coord, "modify_scales"):
393
+ self.coord.modify_scales(self.panel_scales_x, self.panel_scales_y)
394
+
395
+ layout = self.layout
396
+ n_panels = len(layout)
397
+ params_list: List[Dict[str, Any]] = []
398
+
399
+ # Deduplicate by COORD column if available
400
+ if "COORD" in layout.columns:
401
+ unique_coords = layout["COORD"].unique()
402
+ coord_to_params: Dict[Any, Dict[str, Any]] = {}
403
+ for uc in unique_coords:
404
+ row = layout.loc[layout["COORD"] == uc].iloc[0]
405
+ sx_idx = int(row["SCALE_X"]) - 1
406
+ sy_idx = int(row["SCALE_Y"]) - 1
407
+ sx = self.panel_scales_x[sx_idx] if self.panel_scales_x else None
408
+ sy = self.panel_scales_y[sy_idx] if self.panel_scales_y else None
409
+ if hasattr(self.coord, "setup_panel_params"):
410
+ pp = self.coord.setup_panel_params(
411
+ sx, sy, params=self.coord_params,
412
+ )
413
+ else:
414
+ pp = {}
415
+ coord_to_params[uc] = pp
416
+
417
+ # Expand to all panels
418
+ for _, row in layout.iterrows():
419
+ params_list.append(coord_to_params[row["COORD"]])
420
+ else:
421
+ for _, row in layout.iterrows():
422
+ sx_idx = int(row["SCALE_X"]) - 1
423
+ sy_idx = int(row["SCALE_Y"]) - 1
424
+ sx = self.panel_scales_x[sx_idx] if self.panel_scales_x else None
425
+ sy = self.panel_scales_y[sy_idx] if self.panel_scales_y else None
426
+ if hasattr(self.coord, "setup_panel_params"):
427
+ pp = self.coord.setup_panel_params(
428
+ sx, sy, params=self.coord_params,
429
+ )
430
+ else:
431
+ pp = {}
432
+ params_list.append(pp)
433
+
434
+ # Let facet modify panel_params
435
+ if hasattr(self.facet, "setup_panel_params"):
436
+ params_list = self.facet.setup_panel_params(params_list, self.coord)
437
+
438
+ self.panel_params = params_list
439
+
440
+ def setup_panel_guides(self, guides: Any, layers: List[Any]) -> None:
441
+ """Set up and train position guides (axes) per panel.
442
+
443
+ Parameters
444
+ ----------
445
+ guides : Guides
446
+ The plot's guides specification.
447
+ layers : list
448
+ Plot layers.
449
+ """
450
+ if self.panel_params is None:
451
+ return
452
+
453
+ # Setup guides
454
+ if hasattr(self.coord, "setup_panel_guides"):
455
+ self.panel_params = [
456
+ self.coord.setup_panel_guides(
457
+ pp, guides, self.coord_params,
458
+ )
459
+ for pp in self.panel_params
460
+ ]
461
+
462
+ # Train guides
463
+ if hasattr(self.coord, "train_panel_guides"):
464
+ self.panel_params = [
465
+ self.coord.train_panel_guides(
466
+ pp, layers, self.coord_params,
467
+ )
468
+ for pp in self.panel_params
469
+ ]
470
+
471
+ def finish_data(self, data: List[pd.DataFrame]) -> List[pd.DataFrame]:
472
+ """Apply facet's ``finish_data()`` hook.
473
+
474
+ Parameters
475
+ ----------
476
+ data : list of DataFrame
477
+ Layer data.
478
+
479
+ Returns
480
+ -------
481
+ list of DataFrame
482
+ """
483
+ if hasattr(self.facet, "finish_data"):
484
+ return [
485
+ self.facet.finish_data(
486
+ d,
487
+ layout=self.layout,
488
+ x_scales=self.panel_scales_x,
489
+ y_scales=self.panel_scales_y,
490
+ params=self.facet_params,
491
+ )
492
+ for d in data
493
+ ]
494
+ return data
495
+
496
+ # ------------------------------------------------------------------
497
+ # ggplot_gtable phase (render)
498
+ # ------------------------------------------------------------------
499
+
500
+ def render(
501
+ self,
502
+ panels: List[Any],
503
+ data: List[pd.DataFrame],
504
+ theme: Any,
505
+ labels: Dict[str, Any],
506
+ ) -> Any:
507
+ """Render panels, axes, and strips into a gtable.
508
+
509
+ Parameters
510
+ ----------
511
+ panels : list
512
+ Geom grobs per layer (list of lists).
513
+ data : list of DataFrame
514
+ Layer data.
515
+ theme : Theme
516
+ Complete theme.
517
+ labels : dict
518
+ Plot labels.
519
+
520
+ Returns
521
+ -------
522
+ gtable
523
+ The assembled plot table.
524
+ """
525
+ # Draw panel content
526
+ if hasattr(self.facet, "draw_panel_content"):
527
+ panels = self.facet.draw_panel_content(
528
+ panels,
529
+ self.layout,
530
+ self.panel_scales_x,
531
+ self.panel_scales_y,
532
+ self.panel_params,
533
+ self.coord,
534
+ data,
535
+ theme,
536
+ self.facet_params,
537
+ )
538
+
539
+ # Draw panels into gtable
540
+ if hasattr(self.facet, "draw_panels"):
541
+ plot_table = self.facet.draw_panels(
542
+ panels,
543
+ self.layout,
544
+ self.panel_scales_x,
545
+ self.panel_scales_y,
546
+ self.panel_params,
547
+ self.coord,
548
+ data,
549
+ theme,
550
+ self.facet_params,
551
+ )
552
+ else:
553
+ # Minimal fallback
554
+ from gtable_py import Gtable
555
+ plot_table = Gtable()
556
+
557
+ # Set panel sizes
558
+ if hasattr(self.facet, "set_panel_size"):
559
+ plot_table = self.facet.set_panel_size(plot_table, theme)
560
+
561
+ # Resolve axis labels
562
+ resolved_labels = {}
563
+ if self.panel_scales_x and len(self.panel_scales_x) > 0:
564
+ resolved_labels["x"] = self.resolve_label(
565
+ self.panel_scales_x[0], labels,
566
+ )
567
+ if self.panel_scales_y and len(self.panel_scales_y) > 0:
568
+ resolved_labels["y"] = self.resolve_label(
569
+ self.panel_scales_y[0], labels,
570
+ )
571
+
572
+ # Let coord modify labels
573
+ if hasattr(self.coord, "labels") and self.panel_params:
574
+ resolved_labels = self.coord.labels(
575
+ resolved_labels,
576
+ self.panel_params[0] if self.panel_params else {},
577
+ )
578
+
579
+ # Render label grobs
580
+ label_grobs = self.render_labels(resolved_labels, theme)
581
+
582
+ # Draw axis title labels via facet
583
+ if hasattr(self.facet, "draw_labels"):
584
+ plot_table = self.facet.draw_labels(
585
+ plot_table,
586
+ self.layout,
587
+ self.panel_scales_x,
588
+ self.panel_scales_y,
589
+ self.panel_params,
590
+ self.coord,
591
+ data,
592
+ theme,
593
+ label_grobs,
594
+ self.facet_params,
595
+ )
596
+
597
+ return plot_table
598
+
599
+ def resolve_label(
600
+ self,
601
+ scale: Any,
602
+ labels: Dict[str, Any],
603
+ ) -> Dict[str, Any]:
604
+ """Resolve axis titles from guides, scales, or plot labels.
605
+
606
+ Parameters
607
+ ----------
608
+ scale : Scale
609
+ The position scale.
610
+ labels : dict
611
+ Plot labels dictionary.
612
+
613
+ Returns
614
+ -------
615
+ dict
616
+ ``{"primary": ..., "secondary": ...}`` title dict.
617
+ """
618
+ aes = scale.aesthetics[0] if scale.aesthetics else "x"
619
+
620
+ # From scale name
621
+ prim_scale = getattr(scale, "name", None)
622
+ seco_scale = getattr(scale, "sec_name", None)
623
+ if callable(seco_scale):
624
+ seco_scale = seco_scale()
625
+
626
+ # From plot labels
627
+ prim_label = labels.get(aes)
628
+ seco_label = labels.get(f"sec.{aes}")
629
+
630
+ # From scale's make_title
631
+ if hasattr(scale, "make_title"):
632
+ primary = scale.make_title(
633
+ prim_scale if not is_waiver(prim_scale) and prim_scale is not None
634
+ else prim_label
635
+ )
636
+ else:
637
+ primary = prim_scale if prim_scale is not None else prim_label
638
+
639
+ secondary = seco_scale if seco_scale is not None else seco_label
640
+
641
+ return {"primary": primary, "secondary": secondary}
642
+
643
+ def render_labels(
644
+ self,
645
+ labels: Dict[str, Any],
646
+ theme: Any,
647
+ ) -> Dict[str, Any]:
648
+ """Render axis title grobs.
649
+
650
+ Mirrors R's ``Layout$render_labels``: produces text grobs for
651
+ x-axis and y-axis titles. Falls back to a simple ``text_grob``
652
+ when theme ``element_render`` is unavailable.
653
+
654
+ Parameters
655
+ ----------
656
+ labels : dict
657
+ Resolved labels keyed by ``"x"`` / ``"y"``, each
658
+ ``{"primary": ..., "secondary": ...}``.
659
+ theme : Theme
660
+ Complete theme.
661
+
662
+ Returns
663
+ -------
664
+ dict
665
+ ``{"x": [primary_grob, secondary_grob], "y": [...]}``
666
+ """
667
+ from grid_py import null_grob, text_grob, Gpar
668
+
669
+ result: Dict[str, Any] = {}
670
+ for axis, label_pair in labels.items():
671
+ grobs = []
672
+ if not isinstance(label_pair, dict):
673
+ result[axis] = [null_grob(), null_grob()]
674
+ continue
675
+ for i, key in enumerate(["primary", "secondary"]):
676
+ val = label_pair.get(key)
677
+ if val is None or is_waiver(val):
678
+ grobs.append(null_grob())
679
+ else:
680
+ # R: element_render(theme, "axis.title.x.bottom", label=...,
681
+ # margin_x = label == "y", margin_y = label == "x")
682
+ from ggplot2_py.theme_elements import element_render as _el_render
683
+ pos = ".bottom" if axis == "x" else ".left"
684
+ if i == 1:
685
+ pos = ".top" if axis == "x" else ".right"
686
+ g = _el_render(
687
+ theme, f"axis.title.{axis}{pos}",
688
+ label=str(val),
689
+ margin_x=(axis == "y"),
690
+ margin_y=(axis == "x"),
691
+ )
692
+ grobs.append(g)
693
+ result[axis] = grobs
694
+ return result
695
+
696
+ # ------------------------------------------------------------------
697
+ # Utilities
698
+ # ------------------------------------------------------------------
699
+
700
+ def get_scales(self, i: int) -> Dict[str, Any]:
701
+ """Get scales for panel *i*.
702
+
703
+ Parameters
704
+ ----------
705
+ i : int
706
+ Panel index (1-based, matching ``PANEL`` column values).
707
+
708
+ Returns
709
+ -------
710
+ dict
711
+ ``{"x": Scale, "y": Scale}`` for the requested panel.
712
+ """
713
+ row = self.layout.loc[self.layout["PANEL"] == i]
714
+ if row.empty:
715
+ return {"x": None, "y": None}
716
+ row = row.iloc[0]
717
+ sx_idx = int(row["SCALE_X"]) - 1
718
+ sy_idx = int(row["SCALE_Y"]) - 1
719
+ return {
720
+ "x": self.panel_scales_x[sx_idx] if self.panel_scales_x and sx_idx < len(self.panel_scales_x) else None,
721
+ "y": self.panel_scales_y[sy_idx] if self.panel_scales_y and sy_idx < len(self.panel_scales_y) else None,
722
+ }
723
+
724
+
725
+ # ---------------------------------------------------------------------------
726
+ # Factory
727
+ # ---------------------------------------------------------------------------
728
+
729
+ def create_layout(
730
+ facet: Any,
731
+ coord: Any,
732
+ layout_cls: Any = None,
733
+ ) -> Layout:
734
+ """Create a :class:`Layout` instance for a plot.
735
+
736
+ Parameters
737
+ ----------
738
+ facet : Facet
739
+ Faceting specification.
740
+ coord : Coord
741
+ Coordinate system.
742
+ layout_cls : type, optional
743
+ Layout subclass to use (defaults to :class:`Layout`).
744
+
745
+ Returns
746
+ -------
747
+ Layout
748
+ A configured layout instance.
749
+ """
750
+ cls = layout_cls or Layout
751
+ obj = cls()
752
+ obj.facet = facet
753
+ obj.coord = coord
754
+ return obj