ggplot2-python 4.0.2.9000__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ggplot2_py/__init__.py +852 -0
- ggplot2_py/_compat.py +475 -0
- ggplot2_py/_plugins.py +129 -0
- ggplot2_py/_utils.py +544 -0
- ggplot2_py/aes.py +586 -0
- ggplot2_py/annotation.py +540 -0
- ggplot2_py/coord.py +2108 -0
- ggplot2_py/coords/__init__.py +49 -0
- ggplot2_py/datasets.py +265 -0
- ggplot2_py/draw_key.py +454 -0
- ggplot2_py/facet.py +1456 -0
- ggplot2_py/fortify.py +95 -0
- ggplot2_py/geom.py +4516 -0
- ggplot2_py/geoms/__init__.py +12 -0
- ggplot2_py/ggproto.py +279 -0
- ggplot2_py/guide.py +2925 -0
- ggplot2_py/guide_axis.py +615 -0
- ggplot2_py/guide_colourbar.py +657 -0
- ggplot2_py/guide_legend.py +1061 -0
- ggplot2_py/guides/__init__.py +8 -0
- ggplot2_py/labeller.py +296 -0
- ggplot2_py/labels.py +309 -0
- ggplot2_py/layer.py +954 -0
- ggplot2_py/layout.py +754 -0
- ggplot2_py/limits.py +314 -0
- ggplot2_py/plot.py +1401 -0
- ggplot2_py/plot_render.py +866 -0
- ggplot2_py/position.py +1269 -0
- ggplot2_py/protocols.py +171 -0
- ggplot2_py/py.typed +0 -0
- ggplot2_py/qplot.py +233 -0
- ggplot2_py/resources/diamonds.csv +53941 -0
- ggplot2_py/resources/economics.csv +575 -0
- ggplot2_py/resources/economics_long.csv +2871 -0
- ggplot2_py/resources/faithfuld.csv +5626 -0
- ggplot2_py/resources/luv_colours.csv +658 -0
- ggplot2_py/resources/midwest.csv +438 -0
- ggplot2_py/resources/mpg.csv +235 -0
- ggplot2_py/resources/msleep.csv +84 -0
- ggplot2_py/resources/presidential.csv +13 -0
- ggplot2_py/resources/seals.csv +1156 -0
- ggplot2_py/resources/txhousing.csv +8603 -0
- ggplot2_py/save.py +316 -0
- ggplot2_py/scale.py +2727 -0
- ggplot2_py/scales/__init__.py +4252 -0
- ggplot2_py/stat.py +6071 -0
- ggplot2_py/stats/__init__.py +9 -0
- ggplot2_py/theme.py +490 -0
- ggplot2_py/theme_defaults.py +1350 -0
- ggplot2_py/theme_elements.py +2052 -0
- ggplot2_python-4.0.2.9000.dist-info/METADATA +179 -0
- ggplot2_python-4.0.2.9000.dist-info/RECORD +54 -0
- ggplot2_python-4.0.2.9000.dist-info/WHEEL +4 -0
- ggplot2_python-4.0.2.9000.dist-info/licenses/LICENSE +3 -0
|
@@ -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()
|