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
@@ -0,0 +1,866 @@
1
+ """
2
+ Plot rendering functions — conversion from built plot to gtable.
3
+
4
+ Extracted from plot.py to match R's separation of
5
+ plot-build.R (build pipeline) from plot-render.R (rendering).
6
+
7
+ Contains:
8
+ - ggplot_gtable() — convert built plot to gtable
9
+ - _table_add_legends() — build legends from scales
10
+ - _table_add_titles() — add title/subtitle/caption
11
+ - ggplotGrob() — build + render convenience
12
+ - find_panel() / panel_rows() / panel_cols() — panel location
13
+ - print_plot() — render to device
14
+
15
+ R references
16
+ ------------
17
+ * ggplot2/R/plot-render.R
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from functools import singledispatch
23
+ from typing import Any, Dict, List, Optional
24
+
25
+ import numpy as np
26
+ import pandas as pd
27
+
28
+ from ggplot2_py._compat import Waiver, is_waiver, waiver
29
+
30
+ __all__ = [
31
+ "ggplot_gtable",
32
+ "ggplotGrob",
33
+ "_safe_colour",
34
+ "_table_add_legends",
35
+ "_table_add_titles",
36
+ "find_panel",
37
+ "panel_rows",
38
+ "panel_cols",
39
+ "print_plot",
40
+ ]
41
+
42
+
43
+ def _legend_label_width_cm(labels: List[Any], fontsize: float = 6.0) -> float:
44
+ """Measure max label width in cm using Cairo font metrics.
45
+
46
+ Replaces ``max(len(str(l)) for l in labels) * 0.18`` with actual
47
+ text measurement, matching R's ``width_cm(grobs$labels)`` pattern.
48
+ """
49
+ from grid_py._size import calc_string_metric
50
+ from grid_py import Gpar
51
+ max_w = 0.0
52
+ for l in labels:
53
+ m = calc_string_metric(str(l), Gpar(fontsize=fontsize))
54
+ max_w = max(max_w, m["width"] * 2.54) # inches → cm
55
+ return max(max_w, 0.3) # minimum width 0.3 cm
56
+
57
+
58
+ @singledispatch
59
+ def ggplot_gtable(data: Any) -> Any:
60
+ """Convert a built ggplot to a gtable for rendering.
61
+
62
+ This is a :func:`functools.singledispatch` generic (R ref:
63
+ ``plot-render.R:22``, ``UseMethod("ggplot_gtable")``). Extension
64
+ packages can register custom built-plot types::
65
+
66
+ @ggplot_gtable.register(MyBuiltPlot)
67
+ def _gtable_my_plot(data):
68
+ ...
69
+
70
+ Parameters
71
+ ----------
72
+ data : BuiltGGPlot
73
+ Output from :func:`ggplot_build`.
74
+
75
+ Returns
76
+ -------
77
+ gtable
78
+ A gtable suitable for drawing with ``grid_draw()``.
79
+ """
80
+ raise TypeError(
81
+ f"Cannot render object of type {type(data).__name__}. "
82
+ "Expected a BuiltGGPlot instance."
83
+ )
84
+
85
+
86
+ def _ggplot_gtable_impl(data):
87
+ """Core ggplot_gtable implementation for BuiltGGPlot objects."""
88
+ from gtable_py import (
89
+ Gtable,
90
+ gtable_add_grob,
91
+ gtable_add_rows,
92
+ gtable_add_cols,
93
+ gtable_width,
94
+ gtable_height,
95
+ )
96
+ from grid_py import null_grob
97
+
98
+ plot = data.plot
99
+ layout = data.layout
100
+ layer_data = data.data
101
+ theme = plot.theme
102
+ labels = plot.labels
103
+
104
+ # Draw geom grobs for each layer
105
+ geom_grobs: List[Any] = []
106
+ for i, layer in enumerate(plot.layers):
107
+ if hasattr(layer, "draw_geom"):
108
+ geom_grobs.append(layer.draw_geom(layer_data[i], layout))
109
+ else:
110
+ geom_grobs.append(null_grob())
111
+
112
+ # Render panels via layout
113
+ plot_table = layout.render(geom_grobs, layer_data, theme, labels)
114
+
115
+ # Legends — build directly from trained non-position scales.
116
+ plot_table = _table_add_legends(
117
+ plot_table, plot.scales, labels, theme, layers=plot.layers,
118
+ )
119
+
120
+ # Title / subtitle / caption / tag annotations
121
+ plot_table = _table_add_titles(plot_table, labels, theme)
122
+
123
+ # Add plot margin (R: table_add_background, plot-render.R:342-345)
124
+ # R: margin <- calc_element("plot.margin", theme) %||% margin()
125
+ # table <- gtable_add_padding(table, margin)
126
+ # Margin.unit preserves the original unit type (default "pt").
127
+ if hasattr(plot_table, "_widths"):
128
+ from gtable_py import gtable_add_padding
129
+ from grid_py import Unit
130
+ from ggplot2_py.theme_elements import Margin, ElementBlank
131
+ try:
132
+ from ggplot2_py.theme_elements import calc_element as _calc_el
133
+ margin = _calc_el("plot.margin", theme)
134
+ if margin is None or isinstance(margin, ElementBlank):
135
+ margin = Margin(5.5, 5.5, 5.5, 5.5, unit="pt")
136
+ elif not isinstance(margin, Margin):
137
+ margin = Margin(5.5, 5.5, 5.5, 5.5, unit="pt")
138
+ plot_table = gtable_add_padding(plot_table, margin.unit)
139
+ except Exception:
140
+ plot_table = gtable_add_padding(
141
+ plot_table, Unit([0.2, 0.2, 0.2, 0.2], "cm"),
142
+ )
143
+
144
+ # Add alt-text attribute
145
+ if hasattr(plot_table, "__dict__"):
146
+ plot_table._alt_label = labels.get("alt", "")
147
+
148
+ return plot_table
149
+
150
+
151
+ def _safe_colour(colour: Any) -> str:
152
+ """Validate a colour value, returning 'grey50' for invalid inputs."""
153
+ if colour is None:
154
+ return "grey50"
155
+ s = str(colour)
156
+ if s.startswith("#") and len(s) in (7, 9):
157
+ return s
158
+ # Use matplotlib to validate named colours
159
+ try:
160
+ from matplotlib.colors import is_color_like
161
+ if is_color_like(s):
162
+ return s
163
+ except (ImportError, ValueError):
164
+ pass
165
+ return "grey50"
166
+
167
+
168
+ def _table_add_legends(
169
+ table: Any, scales_list: Any, labels: Dict[str, Any], theme: Any,
170
+ layers: Any = None,
171
+ ) -> Any:
172
+ """Build legends from trained non-position scales and add to the gtable.
173
+
174
+ Each legend is built as an independent :class:`~gtable_py.Gtable` with
175
+ its own viewport-based cell layout, faithfully mirroring R's
176
+ ``GuideLegend`` pipeline. Scales sharing the same title and breaks
177
+ are merged into a single legend (R's guide-merge semantics).
178
+
179
+ Mirrors R's ``table_add_legends`` in ``plot-render.R`` and the
180
+ ``GuideLegend`` class in ``guide-legend.R``.
181
+
182
+ Parameters
183
+ ----------
184
+ table : gtable
185
+ scales_list : ScalesList
186
+ labels : dict
187
+ theme : Theme
188
+ layers : list of Layer, optional
189
+ Plot layers — used to determine the ``draw_key`` function for each
190
+ aesthetic.
191
+
192
+ Returns
193
+ -------
194
+ gtable
195
+ """
196
+ if not hasattr(table, "_widths"):
197
+ return table
198
+
199
+ import math
200
+ from gtable_py import gtable_add_grob, gtable_add_cols, gtable_width, gtable_height
201
+ from grid_py import Unit as unit, text_grob, Gpar
202
+
203
+ from ggplot2_py.guide_legend import (
204
+ build_legend_decor,
205
+ build_legend_labels,
206
+ measure_legend_grobs,
207
+ arrange_legend_layout,
208
+ assemble_legend,
209
+ package_legend_box,
210
+ )
211
+
212
+ # ------------------------------------------------------------------
213
+ # 1. Collect raw legend entries from non-position scales
214
+ # ------------------------------------------------------------------
215
+ raw_entries: List[Dict[str, Any]] = []
216
+ np_scales = (
217
+ scales_list.non_position_scales()
218
+ if hasattr(scales_list, "non_position_scales")
219
+ else None
220
+ )
221
+ if np_scales is None or np_scales.n() == 0:
222
+ return table
223
+
224
+ for sc in np_scales.scales:
225
+ aes_name = sc.aesthetics[0] if sc.aesthetics else "unknown"
226
+
227
+ breaks = getattr(sc, "get_breaks", lambda: None)()
228
+ if breaks is None or (hasattr(breaks, "__len__") and len(breaks) == 0):
229
+ continue
230
+
231
+ mapped = breaks
232
+ if hasattr(sc, "map"):
233
+ try:
234
+ mapped = sc.map(breaks)
235
+ except (TypeError, ValueError):
236
+ pass
237
+
238
+ if hasattr(sc, "get_labels"):
239
+ try:
240
+ labs = sc.get_labels(breaks)
241
+ except (TypeError, ValueError, AttributeError):
242
+ labs = [str(b) for b in breaks]
243
+ else:
244
+ labs = [str(b) for b in breaks]
245
+
246
+ # Drop NA/NaN-mapped entries
247
+ keep: List[int] = []
248
+ mapped_arr = np.asarray(mapped) if not isinstance(mapped, np.ndarray) else mapped
249
+ for j in range(len(breaks)):
250
+ val = mapped_arr[j] if j < len(mapped_arr) else None
251
+ try:
252
+ if val is not None and not (isinstance(val, float) and np.isnan(val)):
253
+ keep.append(j)
254
+ except (TypeError, ValueError):
255
+ keep.append(j)
256
+ if not keep:
257
+ continue
258
+ breaks = [breaks[j] for j in keep]
259
+ mapped = [mapped_arr[j] for j in keep]
260
+ labs = [labs[j] for j in keep if j < len(labs)]
261
+
262
+ title = labels.get(aes_name, aes_name)
263
+ if hasattr(title, "__class__") and title.__class__.__name__ == "Waiver":
264
+ title = aes_name
265
+
266
+ raw_entries.append({
267
+ "aesthetic": aes_name,
268
+ "breaks": breaks,
269
+ "mapped": mapped,
270
+ "labels": labs,
271
+ "title": str(title),
272
+ "scale": sc,
273
+ "is_continuous": not getattr(sc, "is_discrete", lambda: True)(),
274
+ "is_binned": sc.__class__.__name__.startswith("ScaleBinned") or
275
+ getattr(sc, "guide", None) in ("bins", "coloursteps"),
276
+ })
277
+
278
+ if not raw_entries:
279
+ return table
280
+
281
+ # ------------------------------------------------------------------
282
+ # 2. Merge entries that share the same title + number of breaks
283
+ # (R merges guides whose hash — based on title and breaks — match)
284
+ # ------------------------------------------------------------------
285
+ merged: Dict[str, Dict[str, Any]] = {}
286
+ for entry in raw_entries:
287
+ key = entry["title"]
288
+ if key in merged and len(merged[key]["breaks"]) == len(entry["breaks"]):
289
+ merged[key]["aes_mapped"][entry["aesthetic"]] = entry["mapped"]
290
+ else:
291
+ merged[key] = {
292
+ "title": entry["title"],
293
+ "breaks": entry["breaks"],
294
+ "labels": entry["labels"],
295
+ "aes_mapped": {entry["aesthetic"]: entry["mapped"]},
296
+ "scale": entry.get("scale"),
297
+ "is_continuous": entry.get("is_continuous", False),
298
+ "is_binned": entry.get("is_binned", False),
299
+ }
300
+ entries = list(merged.values())
301
+
302
+ # ------------------------------------------------------------------
303
+ # 3. Resolve theme elements (R: calc_element for proper inheritance)
304
+ # R always has a complete theme. If Python's theme is None or
305
+ # incomplete, reset the element tree and use theme_grey().
306
+ # ------------------------------------------------------------------
307
+ from ggplot2_py.theme_elements import calc_element as _calc_theme_el
308
+
309
+ if theme is None:
310
+ from ggplot2_py.theme_defaults import theme_grey
311
+ theme = theme_grey()
312
+
313
+ _ltitle_raw = _calc_theme_el("legend.title", theme)
314
+ if _ltitle_raw is None:
315
+ from ggplot2_py.theme_elements import reset_theme_settings
316
+ reset_theme_settings()
317
+ from ggplot2_py.theme_defaults import theme_grey as _tg
318
+ theme = _tg()
319
+ _ltitle_raw = _calc_theme_el("legend.title", theme)
320
+ _ltext_raw = _calc_theme_el("legend.text", theme)
321
+
322
+ title_size = float(_ltitle_raw.size)
323
+ label_size = float(_ltext_raw.size)
324
+ _ltitle_colour = _ltitle_raw.colour
325
+ _ltext_colour = _ltext_raw.colour
326
+
327
+ # Resolve legend key dimensions from theme
328
+ # (R: GuideLegend$override_elements → width_cm/height_cm of theme units)
329
+ from ggplot2_py.theme_elements import calc_element as _calc_el
330
+ from grid_py import Unit as _Unit, convert_width, convert_height
331
+
332
+ def _unit_to_cm(u, axis="height"):
333
+ """Convert a theme Unit to cm using grid's **device-default** gp.
334
+
335
+ Mirrors R's ``convertUnit(u, "cm", valueOnly=TRUE)`` called at
336
+ gtable-construction time (pre-draw, no viewport active): R's
337
+ grid falls back to the device default gp (``fontsize=12``,
338
+ ``lineheight=1.2``). R's ggplot2 uses this device default —
339
+ **not** the theme's ``text`` element — when computing static
340
+ layout sizes such as ``legend.key.width``. grid_py's
341
+ ``convert_*`` with no active viewport reproduces the same
342
+ behaviour, so we just call it directly.
343
+ """
344
+ if u is None or not isinstance(u, _Unit):
345
+ return None
346
+ fn = convert_height if axis == "height" else convert_width
347
+ cm = fn(u, "cm", valueOnly=True)
348
+ val = float(np.sum(cm))
349
+ return val if val > 0 else None
350
+
351
+ key_size = _unit_to_cm(_calc_el("legend.key.size", theme))
352
+ key_w = _unit_to_cm(_calc_el("legend.key.width", theme), "width")
353
+ key_h = _unit_to_cm(_calc_el("legend.key.height", theme))
354
+ spacing_x = _unit_to_cm(_calc_el("legend.key.spacing.x", theme), "width")
355
+ spacing_y = _unit_to_cm(_calc_el("legend.key.spacing.y", theme))
356
+ legend_spacing = _unit_to_cm(_calc_el("legend.spacing", theme))
357
+
358
+ KEY_W_CM = key_w or key_size
359
+ KEY_H_CM = key_h or key_size
360
+ SPACING_X_CM = spacing_x
361
+ SPACING_Y_CM = spacing_y
362
+ PADDING_CM = 0.15 # R: legend.margin default padding
363
+
364
+ # ------------------------------------------------------------------
365
+ # 4. Determine draw_key function from layers
366
+ # ------------------------------------------------------------------
367
+ from ggplot2_py.draw_key import draw_key_point as _draw_key_point
368
+ draw_key_fn = _draw_key_point
369
+ if layers:
370
+ for layer in layers:
371
+ geom = getattr(layer, "geom", None)
372
+ if geom is not None and hasattr(geom, "draw_key"):
373
+ draw_key_fn = geom.draw_key
374
+ break
375
+
376
+ # ------------------------------------------------------------------
377
+ # 5. Build each guide as an independent Gtable
378
+ # Dispatch: continuous colour/fill → colourbar; else → legend
379
+ # ------------------------------------------------------------------
380
+ from ggplot2_py.guide_colourbar import (
381
+ extract_colourbar_decor,
382
+ extract_coloursteps_decor,
383
+ build_colourbar_decor,
384
+ build_coloursteps_decor,
385
+ build_colourbar_labels,
386
+ build_colourbar_ticks,
387
+ assemble_colourbar,
388
+ )
389
+
390
+ # Helper: build a legend title grob with position_margin injection
391
+ # (R guide-legend.R:326-334). Applies equally to discrete-legend
392
+ # titles and colourbar / coloursteps titles so that all three guide
393
+ # flavours have the same visible gap between title and body.
394
+ from ggplot2_py.theme_elements import (
395
+ element_render as _el_render_t,
396
+ calc_element as _calc_el_t,
397
+ Margin as _Margin_t,
398
+ )
399
+
400
+ def _build_legend_title_grob(title_text: str, title_position: str = "top") -> Any:
401
+ _title_el = _calc_el_t("legend.title", theme)
402
+ _gap_pt = 0.0
403
+ try:
404
+ _sp = _calc_el_t("legend.key.spacing.x", theme) or _calc_el_t(
405
+ "legend.key.spacing", theme
406
+ )
407
+ if _sp is not None:
408
+ _gap_pt = float(np.sum(convert_width(_sp, "pt", valueOnly=True)))
409
+ except Exception:
410
+ _gap_pt = 5.5
411
+
412
+ _bm = getattr(_title_el, "margin", None)
413
+ if isinstance(_bm, _Margin_t):
414
+ _mt, _mr, _mb, _ml = float(_bm.t), float(_bm.r), float(_bm.b), float(_bm.l)
415
+ _mu = _bm.unit_str
416
+ if _mu != "pt":
417
+ _gap_val = float(np.sum(convert_width(_Unit(_gap_pt, "pt"), _mu, valueOnly=True)))
418
+ else:
419
+ _gap_val = _gap_pt
420
+ else:
421
+ _mt = _mr = _mb = _ml = 0.0
422
+ _mu = "pt"
423
+ _gap_val = _gap_pt
424
+
425
+ if title_position == "top":
426
+ _mb += _gap_val
427
+ elif title_position == "bottom":
428
+ _mt += _gap_val
429
+ elif title_position == "left":
430
+ _mr += _gap_val
431
+ elif title_position == "right":
432
+ _ml += _gap_val
433
+
434
+ return _el_render_t(
435
+ theme, "legend.title",
436
+ label=str(title_text),
437
+ margin=_Margin_t(t=_mt, r=_mr, b=_mb, l=_ml, unit=_mu),
438
+ margin_x=True, margin_y=True,
439
+ )
440
+
441
+ legend_gtables = []
442
+
443
+ for entry in entries:
444
+ n_breaks = len(entry["breaks"])
445
+ if n_breaks == 0:
446
+ continue
447
+
448
+ aes_names = list(entry["aes_mapped"].keys())
449
+ is_colour_fill = any(a in ("colour", "color", "fill") for a in aes_names)
450
+ is_continuous = entry.get("is_continuous", False)
451
+ is_binned = entry.get("is_binned", False)
452
+ sc = entry.get("scale")
453
+
454
+ # --- Coloursteps path: binned colour/fill scale ---
455
+ if is_colour_fill and is_binned and sc is not None:
456
+ title_grob = _build_legend_title_grob(entry["title"])
457
+
458
+ # Extract stepped colour bins
459
+ decor = extract_coloursteps_decor(
460
+ sc, entry["breaks"], even_steps=True,
461
+ )
462
+
463
+ # Build stepped rectangle bar
464
+ bar_parts = build_coloursteps_decor(decor, direction="vertical")
465
+
466
+ # Labels and ticks (same as colourbar)
467
+ limits = sc.get_limits()
468
+ cb_labels = build_colourbar_labels(
469
+ entry["breaks"], entry["labels"], limits,
470
+ direction="vertical",
471
+ label_size=label_size, label_colour=_ltext_colour,
472
+ )
473
+ ticks = build_colourbar_ticks(
474
+ entry["breaks"], limits, direction="vertical",
475
+ )
476
+
477
+ label_w_cm = _legend_label_width_cm(entry["labels"], label_size)
478
+
479
+ legend_gt = assemble_colourbar(
480
+ bar_grob=bar_parts["bar"],
481
+ frame_grob=bar_parts["frame"],
482
+ ticks_grob=ticks,
483
+ label_grobs=cb_labels,
484
+ title_grob=title_grob,
485
+ direction="vertical",
486
+ bar_width_cm=KEY_W_CM,
487
+ bar_height_cm=KEY_H_CM * 5,
488
+ label_width_cm=label_w_cm,
489
+ padding_cm=PADDING_CM,
490
+ bg_colour="white",
491
+ )
492
+ legend_gtables.append(legend_gt)
493
+ continue
494
+
495
+ # --- Colourbar path: continuous colour/fill scale ---
496
+ if is_colour_fill and is_continuous and sc is not None:
497
+ title_grob = _build_legend_title_grob(entry["title"])
498
+
499
+ # Extract dense colour sequence
500
+ decor = extract_colourbar_decor(sc, nbin=300)
501
+
502
+ # Build bar grob (raster mode)
503
+ bar_parts = build_colourbar_decor(decor, direction="vertical",
504
+ display="raster")
505
+
506
+ # Build tick labels
507
+ limits = sc.get_limits()
508
+ cb_labels = build_colourbar_labels(
509
+ entry["breaks"], entry["labels"], limits,
510
+ direction="vertical",
511
+ label_size=label_size, label_colour=_ltext_colour,
512
+ )
513
+
514
+ # Build tick marks
515
+ ticks = build_colourbar_ticks(
516
+ entry["breaks"], limits, direction="vertical",
517
+ )
518
+
519
+ # Estimate label width
520
+ label_w_cm = _legend_label_width_cm(entry["labels"], label_size)
521
+
522
+ # Assemble
523
+ legend_gt = assemble_colourbar(
524
+ bar_grob=bar_parts["bar"],
525
+ frame_grob=bar_parts["frame"],
526
+ ticks_grob=ticks,
527
+ label_grobs=cb_labels,
528
+ title_grob=title_grob,
529
+ direction="vertical",
530
+ bar_width_cm=KEY_W_CM,
531
+ bar_height_cm=KEY_H_CM * 5,
532
+ label_width_cm=label_w_cm,
533
+ padding_cm=PADDING_CM,
534
+ bg_colour="white",
535
+ )
536
+ legend_gtables.append(legend_gt)
537
+ continue
538
+
539
+ # --- Legend path: discrete scales ---
540
+ nrow = min(n_breaks, 20)
541
+ ncol = 1
542
+
543
+ decor = build_legend_decor(
544
+ entry, draw_key_fn, layers,
545
+ key_width_cm=KEY_W_CM, key_height_cm=KEY_H_CM,
546
+ theme=theme,
547
+ )
548
+
549
+ # R (guide-legend.R:433-450): labels are ``titleGrob``s with
550
+ # the ``legend.text`` element's margin baked in, so
551
+ # ``width_cm(label)`` includes the left/right margins — this is
552
+ # what creates the visible gap between each key and its label.
553
+ # Threading the theme through here gives us that behaviour.
554
+ label_grobs = build_legend_labels(
555
+ entry, label_size=label_size, label_colour=_ltext_colour,
556
+ theme=theme, text_position="right",
557
+ )
558
+
559
+ sizes = measure_legend_grobs(
560
+ decor, label_grobs, n_breaks,
561
+ nrow=nrow, ncol=ncol,
562
+ key_width_cm=KEY_W_CM, key_height_cm=KEY_H_CM,
563
+ spacing_x=SPACING_X_CM, spacing_y=SPACING_Y_CM,
564
+ text_position="right",
565
+ label_size=label_size,
566
+ )
567
+
568
+ layout = arrange_legend_layout(
569
+ n_breaks, nrow=nrow, ncol=ncol,
570
+ text_position="right",
571
+ )
572
+
573
+ # Discrete-legend title with R's position_margin gap injection.
574
+ title_grob = _build_legend_title_grob(entry["title"])
575
+ _title_position = "top"
576
+
577
+ legend_gt = assemble_legend(
578
+ decor, label_grobs, title_grob,
579
+ layout, sizes,
580
+ title_position=_title_position,
581
+ padding_cm=PADDING_CM,
582
+ bg_colour="white",
583
+ )
584
+ legend_gtables.append(legend_gt)
585
+
586
+ if not legend_gtables:
587
+ return table
588
+
589
+ # ------------------------------------------------------------------
590
+ # 6. Package multiple legends into a guide-box
591
+ # ------------------------------------------------------------------
592
+ guide_box = package_legend_box(
593
+ legend_gtables, position="right",
594
+ spacing_cm=legend_spacing,
595
+ )
596
+
597
+ # ------------------------------------------------------------------
598
+ # 7. Place guide-box in the plot table
599
+ # Mirrors R's table_add_legends (plot-render.R:98-105)
600
+ # ------------------------------------------------------------------
601
+ from ggplot2_py.guide_legend import _gtable_total_cm
602
+
603
+ guide_w_cm = _gtable_total_cm(guide_box.widths)
604
+ guide_w_cm = max(guide_w_cm, 1.0)
605
+
606
+ # R: place <- find_panel(table); t=place$t, b=place$b (plot-render.R:96-104)
607
+ place = find_panel(table)
608
+
609
+ table = gtable_add_cols(table, unit([legend_spacing], "cm"), pos=-1)
610
+ table = gtable_add_cols(table, unit([guide_w_cm], "cm"), pos=-1)
611
+ ncol_t = len(table._widths)
612
+ table = gtable_add_grob(
613
+ table, guide_box, t=place["t"], b=place["b"], l=ncol_t,
614
+ clip="off", name="guide-box-right",
615
+ )
616
+
617
+ return table
618
+
619
+
620
+ def _table_add_titles(table: Any, labels: Dict[str, Any], theme: Any) -> Any:
621
+ """Add title, subtitle, caption annotations to the plot table.
622
+
623
+ Mirrors R's ``table_add_titles()`` / ``table_add_caption()`` in
624
+ ``plot-render.R`` (lines 147-224):
625
+ 1. Render the text via ``element_render(theme, element_name, label, ...)``
626
+ 2. Measure actual rendered height via ``grob_height(grob)``
627
+ 3. Add a row of that measured height to the gtable
628
+
629
+ Parameters
630
+ ----------
631
+ table : gtable
632
+ The plot gtable.
633
+ labels : dict
634
+ Plot labels (``title``, ``subtitle``, ``caption``).
635
+ theme : Theme
636
+ Complete theme.
637
+
638
+ Returns
639
+ -------
640
+ gtable
641
+ Modified table.
642
+ """
643
+ from gtable_py import gtable_add_grob, gtable_add_rows
644
+ from grid_py import grob_height
645
+ from ggplot2_py.theme_elements import element_render, calc_element
646
+
647
+ if not hasattr(table, "_widths"):
648
+ return table
649
+
650
+ ncol = len(table._widths)
651
+
652
+ # --- Caption (bottom) --- (R: plot-render.R:193-224)
653
+ caption = labels.get("caption")
654
+ if caption:
655
+ caption_grob = element_render(
656
+ theme, "plot.caption", label=str(caption),
657
+ margin_y=True, margin_x=True,
658
+ )
659
+ caption_height = grob_height(caption_grob)
660
+ table = gtable_add_rows(table, caption_height, pos=-1)
661
+ nrow = len(table._heights)
662
+ table = gtable_add_grob(
663
+ table, caption_grob,
664
+ t=nrow, l=1, r=ncol, clip="off", name="caption",
665
+ )
666
+
667
+ # --- Subtitle (top, added first so title goes above) ---
668
+ # (R: plot-render.R:157-161, 182-184)
669
+ subtitle = labels.get("subtitle")
670
+ if subtitle:
671
+ subtitle_grob = element_render(
672
+ theme, "plot.subtitle", label=str(subtitle),
673
+ margin_y=True, margin_x=True,
674
+ )
675
+ subtitle_height = grob_height(subtitle_grob)
676
+ table = gtable_add_rows(table, subtitle_height, pos=0)
677
+ table = gtable_add_grob(
678
+ table, subtitle_grob,
679
+ t=1, l=1, r=ncol, clip="off", name="subtitle",
680
+ )
681
+
682
+ # --- Title (top) --- (R: plot-render.R:150-154, 186-188)
683
+ title = labels.get("title")
684
+ if title:
685
+ title_grob = element_render(
686
+ theme, "plot.title", label=str(title),
687
+ margin_y=True, margin_x=True,
688
+ )
689
+ title_height = grob_height(title_grob)
690
+ table = gtable_add_rows(table, title_height, pos=0)
691
+ table = gtable_add_grob(
692
+ table, title_grob,
693
+ t=1, l=1, r=ncol, clip="off", name="title",
694
+ )
695
+
696
+ return table
697
+
698
+
699
+ # ---------------------------------------------------------------------------
700
+ # ggplotGrob
701
+ # ---------------------------------------------------------------------------
702
+
703
+ def ggplotGrob(plot: "GGPlot") -> Any:
704
+ """Build and convert a ggplot to a gtable grob.
705
+
706
+ Parameters
707
+ ----------
708
+ plot : GGPlot
709
+ A ggplot object.
710
+
711
+ Returns
712
+ -------
713
+ gtable
714
+ """
715
+ from ggplot2_py.plot import ggplot_build
716
+ return ggplot_gtable(ggplot_build(plot))
717
+
718
+
719
+ def find_panel(table: Any) -> Dict[str, Any]:
720
+ """Find the panel area in a gtable.
721
+
722
+ Mirrors R's ``find_panel()`` in ``layout.R``. Supports gtable layouts
723
+ stored as either a ``pd.DataFrame`` or a plain dict-of-lists.
724
+
725
+ Parameters
726
+ ----------
727
+ table : gtable
728
+ A gtable object.
729
+
730
+ Returns
731
+ -------
732
+ dict
733
+ ``{"t": int, "l": int, "b": int, "r": int}`` panel bounds.
734
+ """
735
+ layout = getattr(table, "layout", None)
736
+ if layout is None:
737
+ return {"t": 1, "l": 1, "b": 1, "r": 1}
738
+
739
+ # --- DataFrame path ---
740
+ if isinstance(layout, pd.DataFrame):
741
+ panel_rows = layout.loc[
742
+ layout["name"].str.contains("panel", case=False, na=False)
743
+ ]
744
+ if not panel_rows.empty:
745
+ return {
746
+ "t": int(panel_rows["t"].min()),
747
+ "l": int(panel_rows["l"].min()),
748
+ "b": int(panel_rows["b"].max()),
749
+ "r": int(panel_rows["r"].max()),
750
+ }
751
+
752
+ # --- dict-of-lists path (gtable_py stores layout this way) ---
753
+ elif isinstance(layout, dict) and "name" in layout:
754
+ names = layout["name"]
755
+ indices = [i for i, n in enumerate(names)
756
+ if isinstance(n, str) and "panel" in n.lower()]
757
+ if indices:
758
+ return {
759
+ "t": min(layout["t"][i] for i in indices),
760
+ "l": min(layout["l"][i] for i in indices),
761
+ "b": max(layout["b"][i] for i in indices),
762
+ "r": max(layout["r"][i] for i in indices),
763
+ }
764
+
765
+ return {"t": 1, "l": 1, "b": 1, "r": 1}
766
+
767
+
768
+ def panel_rows(table: Any) -> Dict[str, int]:
769
+ """Return the row range of panels in a gtable.
770
+
771
+ Parameters
772
+ ----------
773
+ table : gtable
774
+
775
+ Returns
776
+ -------
777
+ dict
778
+ ``{"t": int, "b": int}``
779
+ """
780
+ p = find_panel(table)
781
+ return {"t": p["t"], "b": p["b"]}
782
+
783
+
784
+ def panel_cols(table: Any) -> Dict[str, int]:
785
+ """Return the column range of panels in a gtable.
786
+
787
+ Parameters
788
+ ----------
789
+ table : gtable
790
+
791
+ Returns
792
+ -------
793
+ dict
794
+ ``{"l": int, "r": int}``
795
+ """
796
+ p = find_panel(table)
797
+ return {"l": p["l"], "r": p["r"]}
798
+
799
+
800
+ # ---------------------------------------------------------------------------
801
+ # Matplotlib label helpers
802
+ # ---------------------------------------------------------------------------
803
+
804
+
805
+
806
+ # ---------------------------------------------------------------------------
807
+ # print_plot
808
+ # ---------------------------------------------------------------------------
809
+
810
+ def print_plot(
811
+ plot: "GGPlot",
812
+ newpage: bool = True,
813
+ vp: Any = None,
814
+ ) -> "GGPlot":
815
+ """Render a ggplot to the current device.
816
+
817
+ Parameters
818
+ ----------
819
+ plot : GGPlot
820
+ The plot to display.
821
+ newpage : bool, optional
822
+ If ``True``, create a new page / figure first.
823
+ vp : Viewport, optional
824
+ Viewport to draw in.
825
+
826
+ Returns
827
+ -------
828
+ GGPlot
829
+ The original plot (invisibly).
830
+ """
831
+ from grid_py import grid_draw, grid_newpage
832
+ from ggplot2_py.plot import ggplot_build, set_last_plot
833
+
834
+ set_last_plot(plot)
835
+
836
+ if newpage and vp is None:
837
+ grid_newpage()
838
+
839
+ built = ggplot_build(plot)
840
+ gtable = ggplot_gtable(built)
841
+
842
+ if vp is None:
843
+ grid_draw(gtable)
844
+ else:
845
+ from grid_py import push_viewport, up_viewport
846
+ push_viewport(vp)
847
+ grid_draw(gtable)
848
+ up_viewport()
849
+
850
+ return plot
851
+
852
+
853
+ # ---------------------------------------------------------------------------
854
+ # Deferred singledispatch registration for ggplot_gtable
855
+ # ---------------------------------------------------------------------------
856
+
857
+ def _register_ggplot_gtable_types():
858
+ """Register BuiltGGPlot for ggplot_gtable dispatch.
859
+
860
+ Called from plot.py after BuiltGGPlot is defined.
861
+ """
862
+ from ggplot2_py.plot import BuiltGGPlot
863
+ ggplot_gtable.register(BuiltGGPlot)(_ggplot_gtable_impl)
864
+
865
+
866
+ _register_ggplot_gtable_types()