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,1061 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Legend guide building functions — faithful port of R's GuideLegend.
|
|
3
|
+
|
|
4
|
+
Each legend is built as an independent :class:`~gtable_py.Gtable` with
|
|
5
|
+
its own viewport-based layout. Multiple legends are combined via
|
|
6
|
+
:func:`package_legend_box` into a composite guide-box gtable.
|
|
7
|
+
|
|
8
|
+
R references
|
|
9
|
+
------------
|
|
10
|
+
* ``ggplot2/R/guide-legend.R`` — GuideLegend class
|
|
11
|
+
* ``ggplot2/R/guides-.R`` — Guides$package_box
|
|
12
|
+
* ``ggplot2/R/guide-.R`` — Guide$add_title
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import math
|
|
18
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from grid_py import (
|
|
23
|
+
GList,
|
|
24
|
+
GTree,
|
|
25
|
+
Gpar,
|
|
26
|
+
Unit,
|
|
27
|
+
Viewport,
|
|
28
|
+
null_grob,
|
|
29
|
+
rect_grob,
|
|
30
|
+
text_grob,
|
|
31
|
+
unit_c,
|
|
32
|
+
)
|
|
33
|
+
from grid_py._grob import grob_tree
|
|
34
|
+
|
|
35
|
+
from gtable_py import (
|
|
36
|
+
Gtable,
|
|
37
|
+
gtable_add_cols,
|
|
38
|
+
gtable_add_grob,
|
|
39
|
+
gtable_add_padding,
|
|
40
|
+
gtable_add_row_space,
|
|
41
|
+
gtable_add_rows,
|
|
42
|
+
gtable_col,
|
|
43
|
+
gtable_height,
|
|
44
|
+
gtable_width,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"build_legend_decor",
|
|
49
|
+
"build_legend_labels",
|
|
50
|
+
"measure_legend_grobs",
|
|
51
|
+
"arrange_legend_layout",
|
|
52
|
+
"assemble_legend",
|
|
53
|
+
"add_legend_title",
|
|
54
|
+
"package_legend_box",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Constants (R defaults from ggplot2 theme)
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
_DEFAULT_KEY_WIDTH_CM: float = 0.5 # legend.key.width default ~1.2 lines
|
|
62
|
+
_DEFAULT_KEY_HEIGHT_CM: float = 0.5 # legend.key.height default ~1.2 lines
|
|
63
|
+
_DEFAULT_SPACING_X_CM: float = 0.15 # legend.key.spacing.x
|
|
64
|
+
_DEFAULT_SPACING_Y_CM: float = 0.0 # legend.key.spacing.y (vertical: 0)
|
|
65
|
+
_DEFAULT_PADDING_CM: float = 0.15 # legend.margin
|
|
66
|
+
_DEFAULT_LABEL_SIZE: float = 6.0 # legend.text size (pt)
|
|
67
|
+
_DEFAULT_TITLE_SIZE: float = 7.0 # legend.title size (pt)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _text_width_cm(text: str, fontsize: float = 10.0) -> float:
|
|
71
|
+
"""Measure text width in cm using Cairo font metrics.
|
|
72
|
+
|
|
73
|
+
Replaces the old ``len(text) * 0.18`` character-count heuristic with
|
|
74
|
+
actual font measurement, matching R's ``width_cm(label_grob)`` pattern
|
|
75
|
+
(utilities-grid.R:67-77).
|
|
76
|
+
"""
|
|
77
|
+
from grid_py._size import calc_string_metric
|
|
78
|
+
m = calc_string_metric(text, Gpar(fontsize=fontsize))
|
|
79
|
+
return m["width"] * 2.54 # inches → cm
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _text_height_cm(text: str, fontsize: float = 10.0) -> float:
|
|
83
|
+
"""Measure text height in cm using Cairo font metrics.
|
|
84
|
+
|
|
85
|
+
Matches R's ``height_cm(label_grob)`` pattern.
|
|
86
|
+
"""
|
|
87
|
+
from grid_py._size import calc_string_metric
|
|
88
|
+
m = calc_string_metric(text, Gpar(fontsize=fontsize))
|
|
89
|
+
return (m["ascent"] + m["descent"]) * 2.54 # inches → cm
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# build_legend_decor
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def build_legend_decor(
|
|
97
|
+
entry: Dict[str, Any],
|
|
98
|
+
draw_key_fn: Callable,
|
|
99
|
+
layers: Any,
|
|
100
|
+
key_width_cm: float = _DEFAULT_KEY_WIDTH_CM,
|
|
101
|
+
key_height_cm: float = _DEFAULT_KEY_HEIGHT_CM,
|
|
102
|
+
theme: Any = None,
|
|
103
|
+
) -> List[Any]:
|
|
104
|
+
"""Build legend key glyphs for one merged entry.
|
|
105
|
+
|
|
106
|
+
For each break index, calls *draw_key_fn* with the aesthetic data for
|
|
107
|
+
that break, then wraps the resulting grob in a ``GTree`` whose viewport
|
|
108
|
+
is sized ``key_width_cm x key_height_cm``.
|
|
109
|
+
|
|
110
|
+
Mirrors ``GuideLegend$build_decor`` in R (guide-legend.R:396-431).
|
|
111
|
+
|
|
112
|
+
Parameters
|
|
113
|
+
----------
|
|
114
|
+
entry : dict
|
|
115
|
+
Merged legend entry with keys ``aes_mapped``, ``breaks``, ``labels``.
|
|
116
|
+
draw_key_fn : callable
|
|
117
|
+
The geom's ``draw_key`` function (e.g. ``draw_key_point``).
|
|
118
|
+
layers : list
|
|
119
|
+
Plot layers (used to detect geom params).
|
|
120
|
+
key_width_cm, key_height_cm : float
|
|
121
|
+
Key glyph dimensions in cm.
|
|
122
|
+
|
|
123
|
+
Returns
|
|
124
|
+
-------
|
|
125
|
+
list of grob
|
|
126
|
+
One glyph GTree per break.
|
|
127
|
+
"""
|
|
128
|
+
from ggplot2_py.plot import _safe_colour
|
|
129
|
+
|
|
130
|
+
aes_mapped = entry["aes_mapped"]
|
|
131
|
+
n_breaks = len(entry["breaks"])
|
|
132
|
+
|
|
133
|
+
# Resolve layer params (first layer only, like R).
|
|
134
|
+
# R (guide-legend.R:396-410): ``build_decor`` passes per-break
|
|
135
|
+
# ``data`` (from the scale) *merged* with the layer's fixed
|
|
136
|
+
# ``aes_params`` (e.g. ``fill='red'`` when the user wrote
|
|
137
|
+
# ``geom_point(shape=21, fill='red')``). If we only forward
|
|
138
|
+
# the mapped aesthetics, a legend key for a shape=21 layer with
|
|
139
|
+
# ``fill='red'`` shows up as a black disc instead of a red ring.
|
|
140
|
+
layer_params: Dict[str, Any] = {}
|
|
141
|
+
layer_aes_params: Dict[str, Any] = {}
|
|
142
|
+
geom_default_aes: Dict[str, Any] = {}
|
|
143
|
+
if layers:
|
|
144
|
+
for layer in layers:
|
|
145
|
+
geom = getattr(layer, "geom", None)
|
|
146
|
+
if geom is not None:
|
|
147
|
+
# R (guide-legend.R:408): data passed to draw_key is the
|
|
148
|
+
# decoration's data, which was populated with the
|
|
149
|
+
# geom's ``default_aes`` resolved through the active
|
|
150
|
+
# theme — NOT hardcoded black/grey. We mirror that by
|
|
151
|
+
# evaluating FromTheme markers in default_aes now.
|
|
152
|
+
raw = getattr(geom, "default_aes", None)
|
|
153
|
+
if raw is not None:
|
|
154
|
+
try:
|
|
155
|
+
from ggplot2_py.geom import _eval_from_theme
|
|
156
|
+
resolved = _eval_from_theme(raw, theme)
|
|
157
|
+
geom_default_aes = dict(resolved.items()) if hasattr(resolved, "items") else dict(resolved)
|
|
158
|
+
except Exception:
|
|
159
|
+
geom_default_aes = {}
|
|
160
|
+
layer_params = getattr(layer, "computed_geom_params", {})
|
|
161
|
+
if not layer_params:
|
|
162
|
+
layer_params = getattr(geom, "default_params", {})
|
|
163
|
+
if callable(layer_params):
|
|
164
|
+
layer_params = layer_params()
|
|
165
|
+
layer_aes_params = dict(getattr(layer, "aes_params", {}) or {})
|
|
166
|
+
break
|
|
167
|
+
|
|
168
|
+
# Key size passed to draw_key as mm (R multiplies by 10 from cm)
|
|
169
|
+
key_size = (key_width_cm * 10, key_height_cm * 10)
|
|
170
|
+
|
|
171
|
+
# Key background grob (R: element_grob(elements$key))
|
|
172
|
+
key_bg = rect_grob(
|
|
173
|
+
gp=Gpar(fill="white", col="grey90", lwd=0.5),
|
|
174
|
+
name="legend.key.bg",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
key_glyphs = []
|
|
178
|
+
for i in range(n_breaks):
|
|
179
|
+
# Build the aesthetic data dict for this break
|
|
180
|
+
data: Dict[str, Any] = {}
|
|
181
|
+
for aes_name, mapped_vals in aes_mapped.items():
|
|
182
|
+
val = mapped_vals[i] if i < len(mapped_vals) else None
|
|
183
|
+
if aes_name in ("colour", "color"):
|
|
184
|
+
data["colour"] = _safe_colour(val)
|
|
185
|
+
elif aes_name == "fill":
|
|
186
|
+
data["fill"] = _safe_colour(val)
|
|
187
|
+
elif aes_name == "shape":
|
|
188
|
+
data["shape"] = int(val) if val is not None else 19
|
|
189
|
+
elif aes_name == "size":
|
|
190
|
+
try:
|
|
191
|
+
data["size"] = float(val) if val is not None else 1.5
|
|
192
|
+
if np.isnan(data["size"]):
|
|
193
|
+
data["size"] = 1.5
|
|
194
|
+
except (TypeError, ValueError):
|
|
195
|
+
data["size"] = 1.5
|
|
196
|
+
elif aes_name == "linetype":
|
|
197
|
+
data["linetype"] = val
|
|
198
|
+
elif aes_name == "linewidth":
|
|
199
|
+
data["linewidth"] = val
|
|
200
|
+
elif aes_name == "alpha":
|
|
201
|
+
data["alpha"] = val
|
|
202
|
+
else:
|
|
203
|
+
data[aes_name] = val
|
|
204
|
+
|
|
205
|
+
# Merge layer fixed aes_params on top of any mapped aesthetics.
|
|
206
|
+
# R (guide-legend.R:404) slices the decoration's ``data`` which
|
|
207
|
+
# already contains fixed params via ``Layer$compute_aesthetics``.
|
|
208
|
+
# Fixed params win when both are present (matches R's
|
|
209
|
+
# ``data <- vec_slice(dec$data, i)`` behaviour where fixed
|
|
210
|
+
# values are written into the data frame).
|
|
211
|
+
for k, v in layer_aes_params.items():
|
|
212
|
+
if k in ("colour", "color"):
|
|
213
|
+
data["colour"] = _safe_colour(v)
|
|
214
|
+
elif k == "fill":
|
|
215
|
+
data["fill"] = _safe_colour(v)
|
|
216
|
+
elif k == "shape" and v is not None:
|
|
217
|
+
try:
|
|
218
|
+
data["shape"] = int(v)
|
|
219
|
+
except (TypeError, ValueError):
|
|
220
|
+
data["shape"] = v
|
|
221
|
+
elif v is not None:
|
|
222
|
+
data[k] = v
|
|
223
|
+
|
|
224
|
+
# Seed defaults from the geom's theme-resolved default_aes.
|
|
225
|
+
# R (guide-legend.R:404-408): per-break ``data`` already
|
|
226
|
+
# contains the geom's theme defaults; e.g. GeomDensity's
|
|
227
|
+
# ``fill = from_theme(fill %||% NA)`` resolves to NA, so the
|
|
228
|
+
# legend key is *transparent* — not black. Only the
|
|
229
|
+
# ultra-fallbacks below kick in when the geom provides
|
|
230
|
+
# nothing (no layer/no default_aes).
|
|
231
|
+
for _dk, _dv in geom_default_aes.items():
|
|
232
|
+
data.setdefault(_dk, _dv)
|
|
233
|
+
|
|
234
|
+
data.setdefault("colour", None) # R: NA (no border by default)
|
|
235
|
+
data.setdefault("fill", None) # R: NA (no fill by default)
|
|
236
|
+
data.setdefault("size", 1.5)
|
|
237
|
+
data.setdefault("alpha", None)
|
|
238
|
+
data.setdefault("stroke", 0.5)
|
|
239
|
+
data.setdefault("shape", 19)
|
|
240
|
+
data.setdefault("linetype", 1)
|
|
241
|
+
data.setdefault("linewidth", 0.5)
|
|
242
|
+
|
|
243
|
+
# Call the draw_key function.
|
|
244
|
+
# draw_key_fn may be a bound method (from ggproto) or a plain function.
|
|
245
|
+
# Try plain call first; if TypeError (too many args from bound self),
|
|
246
|
+
# extract the underlying function.
|
|
247
|
+
try:
|
|
248
|
+
glyph = draw_key_fn(data, layer_params, key_size)
|
|
249
|
+
except TypeError:
|
|
250
|
+
# Likely a bound method — get the underlying function
|
|
251
|
+
fn = getattr(draw_key_fn, "__func__", draw_key_fn)
|
|
252
|
+
glyph = fn(data, layer_params, key_size)
|
|
253
|
+
|
|
254
|
+
# --- set_key_size (R: guide-legend.R:626-641) ---
|
|
255
|
+
# Compute glyph physical size from aesthetics: (size + linewidth) / 10
|
|
256
|
+
# This converts mm to cm, matching R's set_key_size().
|
|
257
|
+
glyph_w = getattr(glyph, "_width", None)
|
|
258
|
+
glyph_h = getattr(glyph, "_height", None)
|
|
259
|
+
if glyph_w is None or glyph_h is None:
|
|
260
|
+
_size = data.get("size", 0) or 0
|
|
261
|
+
_lwd = data.get("linewidth", 0) or 0
|
|
262
|
+
_stroke = data.get("stroke", 0) or 0
|
|
263
|
+
try:
|
|
264
|
+
_size = float(_size) if not (isinstance(_size, float) and np.isnan(_size)) else 0
|
|
265
|
+
except (TypeError, ValueError):
|
|
266
|
+
_size = 0
|
|
267
|
+
try:
|
|
268
|
+
_lwd = float(_lwd) if not (isinstance(_lwd, float) and np.isnan(_lwd)) else 0
|
|
269
|
+
except (TypeError, ValueError):
|
|
270
|
+
_lwd = 0
|
|
271
|
+
try:
|
|
272
|
+
_stroke = float(_stroke) if not (isinstance(_stroke, float) and np.isnan(_stroke)) else 0
|
|
273
|
+
except (TypeError, ValueError):
|
|
274
|
+
_stroke = 0
|
|
275
|
+
measured_cm = (_size + _lwd + _stroke) / 10.0
|
|
276
|
+
if glyph_w is None:
|
|
277
|
+
glyph_w = measured_cm
|
|
278
|
+
if glyph_h is None:
|
|
279
|
+
glyph_h = measured_cm
|
|
280
|
+
|
|
281
|
+
# Effective key size = max(default, measured glyph size)
|
|
282
|
+
eff_w = max(key_width_cm, glyph_w, 0)
|
|
283
|
+
eff_h = max(key_height_cm, glyph_h, 0)
|
|
284
|
+
|
|
285
|
+
# Wrap in a GTree with a justified viewport (R: build_decor lines 417-428)
|
|
286
|
+
vp = Viewport(
|
|
287
|
+
x=0.5, y=0.5, just="centre",
|
|
288
|
+
width=Unit(eff_w, "cm"),
|
|
289
|
+
height=Unit(eff_h, "cm"),
|
|
290
|
+
)
|
|
291
|
+
key_grob = GTree(
|
|
292
|
+
children=GList(key_bg, glyph),
|
|
293
|
+
vp=vp,
|
|
294
|
+
name=f"key-{i}",
|
|
295
|
+
)
|
|
296
|
+
# Store measured size on grob (R: attr(grob, "width") <- width)
|
|
297
|
+
key_grob._width = eff_w
|
|
298
|
+
key_grob._height = eff_h
|
|
299
|
+
key_glyphs.append(key_grob)
|
|
300
|
+
|
|
301
|
+
return key_glyphs
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
# build_legend_labels
|
|
306
|
+
# ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
def build_legend_labels(
|
|
309
|
+
entry: Dict[str, Any],
|
|
310
|
+
label_size: float = _DEFAULT_LABEL_SIZE,
|
|
311
|
+
label_colour: str = "grey20",
|
|
312
|
+
theme: Any = None,
|
|
313
|
+
text_position: str = "right",
|
|
314
|
+
) -> List[Any]:
|
|
315
|
+
"""Build text grobs for legend labels.
|
|
316
|
+
|
|
317
|
+
Mirrors ``GuideLegend$build_labels`` (guide-legend.R:433-450)::
|
|
318
|
+
|
|
319
|
+
element_grob(elements$text, label = lab,
|
|
320
|
+
margin_x = TRUE, margin_y = TRUE)
|
|
321
|
+
|
|
322
|
+
That call produces a ``titleGrob`` whose ``grobWidth`` / ``grobHeight``
|
|
323
|
+
include the theme element's margins. ``measure_grobs`` subsequently
|
|
324
|
+
uses those widths to size the label column, which is why R leaves
|
|
325
|
+
visible space between each key and its label. A bare ``text_grob``
|
|
326
|
+
(no margin) shrinks the column to the glyph box and the label text
|
|
327
|
+
ends up kissing the key rectangle.
|
|
328
|
+
|
|
329
|
+
Parameters
|
|
330
|
+
----------
|
|
331
|
+
entry : dict
|
|
332
|
+
Merged legend entry.
|
|
333
|
+
label_size : float
|
|
334
|
+
Font size in points (used only as a fallback when *theme* is
|
|
335
|
+
not provided).
|
|
336
|
+
label_colour : str
|
|
337
|
+
Font colour (fallback when *theme* is not provided).
|
|
338
|
+
theme : Theme or None
|
|
339
|
+
When given, labels are produced via
|
|
340
|
+
``element_render(theme, "legend.text", ...)`` so that the
|
|
341
|
+
theme's ``legend.text`` element (fontsize, colour, hjust, vjust,
|
|
342
|
+
angle, margin) drives rendering — matching R exactly.
|
|
343
|
+
|
|
344
|
+
Returns
|
|
345
|
+
-------
|
|
346
|
+
list of grob
|
|
347
|
+
One ``_TitleGrob`` (or ``text_grob``) per label.
|
|
348
|
+
"""
|
|
349
|
+
labels = entry.get("labels", [])
|
|
350
|
+
if not labels:
|
|
351
|
+
return [null_grob()]
|
|
352
|
+
|
|
353
|
+
# Preferred path: route through element_render so the resulting
|
|
354
|
+
# _TitleGrob carries legend.text's margin.
|
|
355
|
+
if theme is not None:
|
|
356
|
+
from ggplot2_py.theme_elements import (
|
|
357
|
+
element_render as _el_render,
|
|
358
|
+
calc_element as _calc,
|
|
359
|
+
Margin as _Margin,
|
|
360
|
+
)
|
|
361
|
+
# R (guide-legend.R:336-349 setup_elements):
|
|
362
|
+
# margin <- position_margin(text_position, base_margin, gap)
|
|
363
|
+
# elements$text <- calc_element("legend.text", ...with injected margin)
|
|
364
|
+
# gap = legend.key.spacing (5.5pt default). The gap is added to
|
|
365
|
+
# the side of the margin OPPOSITE to ``text_position`` so that
|
|
366
|
+
# it sits between the key and the label.
|
|
367
|
+
text_el = _calc("legend.text", theme)
|
|
368
|
+
gap_pt = 0.0
|
|
369
|
+
try:
|
|
370
|
+
from grid_py import convert_width as _cw
|
|
371
|
+
spacing = _calc("legend.key.spacing.x", theme) or _calc(
|
|
372
|
+
"legend.key.spacing", theme
|
|
373
|
+
)
|
|
374
|
+
if spacing is not None:
|
|
375
|
+
gap_pt = float(np.sum(_cw(spacing, "pt", valueOnly=True)))
|
|
376
|
+
except Exception:
|
|
377
|
+
gap_pt = 5.5 # R default fallback (not a Python invention)
|
|
378
|
+
|
|
379
|
+
base_margin = getattr(text_el, "margin", None)
|
|
380
|
+
if isinstance(base_margin, _Margin):
|
|
381
|
+
mt, mr, mb, ml = (
|
|
382
|
+
float(base_margin.t), float(base_margin.r),
|
|
383
|
+
float(base_margin.b), float(base_margin.l),
|
|
384
|
+
)
|
|
385
|
+
mu = base_margin.unit_str
|
|
386
|
+
# Convert gap_pt to base_margin's unit if not pt
|
|
387
|
+
if mu != "pt":
|
|
388
|
+
from grid_py import Unit as _U, convert_width as _cw
|
|
389
|
+
gap_val = float(np.sum(_cw(_U(gap_pt, "pt"), mu, valueOnly=True)))
|
|
390
|
+
else:
|
|
391
|
+
gap_val = gap_pt
|
|
392
|
+
else:
|
|
393
|
+
mt = mr = mb = ml = 0.0
|
|
394
|
+
mu = "pt"
|
|
395
|
+
gap_val = gap_pt
|
|
396
|
+
|
|
397
|
+
# R position_margin(position, margin, gap):
|
|
398
|
+
# right → margin[4] (left) += gap
|
|
399
|
+
# left → margin[2] (right) += gap
|
|
400
|
+
# top → margin[3] (bottom) += gap
|
|
401
|
+
# bottom → margin[1] (top) += gap
|
|
402
|
+
if text_position == "right":
|
|
403
|
+
ml += gap_val
|
|
404
|
+
elif text_position == "left":
|
|
405
|
+
mr += gap_val
|
|
406
|
+
elif text_position == "top":
|
|
407
|
+
mb += gap_val
|
|
408
|
+
elif text_position == "bottom":
|
|
409
|
+
mt += gap_val
|
|
410
|
+
|
|
411
|
+
injected = _Margin(t=mt, r=mr, b=mb, l=ml, unit=mu)
|
|
412
|
+
return [
|
|
413
|
+
_el_render(
|
|
414
|
+
theme, "legend.text",
|
|
415
|
+
label=str(lab),
|
|
416
|
+
margin=injected,
|
|
417
|
+
margin_x=True, margin_y=True,
|
|
418
|
+
)
|
|
419
|
+
for lab in labels
|
|
420
|
+
]
|
|
421
|
+
|
|
422
|
+
# Fallback: legacy plain text_grob (no margin). Callers that don't
|
|
423
|
+
# thread the theme down will lose the key↔label gap, which is
|
|
424
|
+
# acceptable as a pre-theme-init emergency default.
|
|
425
|
+
grobs = []
|
|
426
|
+
for lab in labels:
|
|
427
|
+
grobs.append(text_grob(
|
|
428
|
+
label=str(lab),
|
|
429
|
+
x=0.0,
|
|
430
|
+
y=0.5,
|
|
431
|
+
just=("left", "centre"),
|
|
432
|
+
gp=Gpar(fontsize=label_size, col=label_colour),
|
|
433
|
+
name=f"guide.label.{lab}",
|
|
434
|
+
))
|
|
435
|
+
return grobs
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# ---------------------------------------------------------------------------
|
|
439
|
+
# measure_legend_grobs
|
|
440
|
+
# ---------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
def measure_legend_grobs(
|
|
443
|
+
decor: List[Any],
|
|
444
|
+
labels: List[Any],
|
|
445
|
+
n_breaks: int,
|
|
446
|
+
nrow: int,
|
|
447
|
+
ncol: int,
|
|
448
|
+
key_width_cm: float = _DEFAULT_KEY_WIDTH_CM,
|
|
449
|
+
key_height_cm: float = _DEFAULT_KEY_HEIGHT_CM,
|
|
450
|
+
spacing_x: float = _DEFAULT_SPACING_X_CM,
|
|
451
|
+
spacing_y: float = _DEFAULT_SPACING_Y_CM,
|
|
452
|
+
text_position: str = "right",
|
|
453
|
+
byrow: bool = False,
|
|
454
|
+
label_size: float = _DEFAULT_LABEL_SIZE,
|
|
455
|
+
) -> Dict[str, List[float]]:
|
|
456
|
+
"""Measure keys and labels, compute gtable widths/heights with spacing.
|
|
457
|
+
|
|
458
|
+
Mirrors ``GuideLegend$measure_grobs`` (guide-legend.R:452-501).
|
|
459
|
+
|
|
460
|
+
The returned widths/heights include interleaved spacing columns/rows
|
|
461
|
+
between key and label cells. For ``text_position="right"`` (default)
|
|
462
|
+
the column pattern is: [key_w, label_w, gap, key_w, label_w, gap, ...]
|
|
463
|
+
(last gap stripped).
|
|
464
|
+
|
|
465
|
+
Parameters
|
|
466
|
+
----------
|
|
467
|
+
decor : list of grob
|
|
468
|
+
Legend key glyphs.
|
|
469
|
+
labels : list of grob
|
|
470
|
+
Legend label grobs.
|
|
471
|
+
n_breaks : int
|
|
472
|
+
Number of legend breaks.
|
|
473
|
+
nrow, ncol : int
|
|
474
|
+
Legend grid dimensions.
|
|
475
|
+
key_width_cm, key_height_cm : float
|
|
476
|
+
Default key dimensions in cm.
|
|
477
|
+
spacing_x, spacing_y : float
|
|
478
|
+
Gap between columns / rows in cm.
|
|
479
|
+
text_position : str
|
|
480
|
+
Where labels go relative to keys: "right", "left", "top", "bottom".
|
|
481
|
+
byrow : bool
|
|
482
|
+
Fill matrix by row?
|
|
483
|
+
|
|
484
|
+
Returns
|
|
485
|
+
-------
|
|
486
|
+
dict
|
|
487
|
+
``{"widths": [...], "heights": [...]}`` in cm.
|
|
488
|
+
"""
|
|
489
|
+
# Pad to fill the nrow x ncol matrix
|
|
490
|
+
pad = nrow * ncol - n_breaks
|
|
491
|
+
|
|
492
|
+
# Key sizes: read dynamic _width/_height from decor grobs (set by set_key_size
|
|
493
|
+
# in build_legend_decor), then take column-max / row-max.
|
|
494
|
+
# Mirrors R's measure_legend_keys / get_key_size (guide-legend.R:595-624).
|
|
495
|
+
key_w_per_entry = []
|
|
496
|
+
key_h_per_entry = []
|
|
497
|
+
for i in range(n_breaks):
|
|
498
|
+
if i < len(decor):
|
|
499
|
+
kw = getattr(decor[i], "_width", key_width_cm) or key_width_cm
|
|
500
|
+
kh = getattr(decor[i], "_height", key_height_cm) or key_height_cm
|
|
501
|
+
else:
|
|
502
|
+
kw, kh = key_width_cm, key_height_cm
|
|
503
|
+
key_w_per_entry.append(max(kw, key_width_cm))
|
|
504
|
+
key_h_per_entry.append(max(kh, key_height_cm))
|
|
505
|
+
# Pad with zeros
|
|
506
|
+
key_w_per_entry.extend([0.0] * pad)
|
|
507
|
+
key_h_per_entry.extend([0.0] * pad)
|
|
508
|
+
|
|
509
|
+
# Arrange into matrix and take column-max / row-max
|
|
510
|
+
if byrow:
|
|
511
|
+
kw_matrix = _fill_matrix(key_w_per_entry, nrow, ncol, byrow=True)
|
|
512
|
+
kh_matrix = _fill_matrix(key_h_per_entry, nrow, ncol, byrow=True)
|
|
513
|
+
else:
|
|
514
|
+
kw_matrix = _fill_matrix(key_w_per_entry, nrow, ncol, byrow=False)
|
|
515
|
+
kh_matrix = _fill_matrix(key_h_per_entry, nrow, ncol, byrow=False)
|
|
516
|
+
|
|
517
|
+
key_widths = [max(kw_matrix[r][c] for r in range(nrow)) for c in range(ncol)]
|
|
518
|
+
key_heights = [max(kh_matrix[r][c] for c in range(ncol)) for r in range(nrow)]
|
|
519
|
+
|
|
520
|
+
# Label sizes: R (guide-legend.R:470-477) does:
|
|
521
|
+
# label_widths = apply(matrix(width_cm(grobs$labels), ...), 2, max)
|
|
522
|
+
# label_heights = apply(matrix(height_cm(grobs$labels), ...), 1, max)
|
|
523
|
+
# where ``grobs$labels`` are titleGrobs with margins, so ``width_cm``
|
|
524
|
+
# returns glyph_width + margin_left + margin_right. When the label
|
|
525
|
+
# has no titleGrob wrapping, fall back to bare text width.
|
|
526
|
+
from grid_py import convert_width as _cw, convert_height as _ch, grob_width as _gw, grob_height as _gh
|
|
527
|
+
def _measure_label_w(g) -> float:
|
|
528
|
+
try:
|
|
529
|
+
u = _gw(g)
|
|
530
|
+
return float(np.sum(_cw(u, "cm", valueOnly=True)))
|
|
531
|
+
except Exception:
|
|
532
|
+
return 0.0
|
|
533
|
+
def _measure_label_h(g) -> float:
|
|
534
|
+
try:
|
|
535
|
+
u = _gh(g)
|
|
536
|
+
return float(np.sum(_ch(u, "cm", valueOnly=True)))
|
|
537
|
+
except Exception:
|
|
538
|
+
return 0.0
|
|
539
|
+
|
|
540
|
+
label_w_per_entry = []
|
|
541
|
+
label_h_per_entry = []
|
|
542
|
+
for lab_grob in labels:
|
|
543
|
+
w = _measure_label_w(lab_grob)
|
|
544
|
+
h = _measure_label_h(lab_grob)
|
|
545
|
+
if w <= 0:
|
|
546
|
+
# Last-resort fallback: measure the bare label text.
|
|
547
|
+
label_text = ""
|
|
548
|
+
if hasattr(lab_grob, "label"):
|
|
549
|
+
label_text = str(lab_grob.label)
|
|
550
|
+
elif hasattr(lab_grob, "_label"):
|
|
551
|
+
label_text = str(lab_grob._label)
|
|
552
|
+
w = (_text_width_cm(label_text, fontsize=label_size)
|
|
553
|
+
if label_text else 0.3)
|
|
554
|
+
if h <= 0:
|
|
555
|
+
h = key_height_cm
|
|
556
|
+
label_w_per_entry.append(w)
|
|
557
|
+
label_h_per_entry.append(h)
|
|
558
|
+
|
|
559
|
+
# Pad to fill the nrow x ncol matrix
|
|
560
|
+
label_w_per_entry.extend([0.0] * pad)
|
|
561
|
+
label_h_per_entry.extend([0.0] * pad)
|
|
562
|
+
|
|
563
|
+
# Arrange into matrix and take column-max / row-max
|
|
564
|
+
if byrow:
|
|
565
|
+
# Fill by row
|
|
566
|
+
label_w_matrix = _fill_matrix(label_w_per_entry, nrow, ncol, byrow=True)
|
|
567
|
+
label_h_matrix = _fill_matrix(label_h_per_entry, nrow, ncol, byrow=True)
|
|
568
|
+
else:
|
|
569
|
+
label_w_matrix = _fill_matrix(label_w_per_entry, nrow, ncol, byrow=False)
|
|
570
|
+
label_h_matrix = _fill_matrix(label_h_per_entry, nrow, ncol, byrow=False)
|
|
571
|
+
|
|
572
|
+
label_widths = [max(label_w_matrix[r][c] for r in range(nrow))
|
|
573
|
+
for c in range(ncol)]
|
|
574
|
+
label_heights = [max(label_h_matrix[r][c] for c in range(ncol))
|
|
575
|
+
for r in range(nrow)]
|
|
576
|
+
|
|
577
|
+
# Interleave widths: [key_w, label_w, hgap] per column, strip last hgap
|
|
578
|
+
if text_position == "right":
|
|
579
|
+
width_lists = _interleave(key_widths, label_widths, spacing_x)
|
|
580
|
+
elif text_position == "left":
|
|
581
|
+
width_lists = _interleave(label_widths, key_widths, spacing_x)
|
|
582
|
+
else:
|
|
583
|
+
# top/bottom: labels and keys share same column
|
|
584
|
+
width_lists = _interleave(
|
|
585
|
+
[max(kw, lw) for kw, lw in zip(key_widths, label_widths)],
|
|
586
|
+
None, spacing_x)
|
|
587
|
+
|
|
588
|
+
# Interleave heights: [key_h, vgap] per row, strip last vgap
|
|
589
|
+
if text_position == "top":
|
|
590
|
+
height_lists = _interleave(label_heights, key_heights, spacing_y)
|
|
591
|
+
elif text_position == "bottom":
|
|
592
|
+
height_lists = _interleave(key_heights, label_heights, spacing_y)
|
|
593
|
+
else:
|
|
594
|
+
# left/right: labels and keys share same row
|
|
595
|
+
height_lists = _interleave(
|
|
596
|
+
[max(kh, lh) for kh, lh in zip(key_heights, label_heights)],
|
|
597
|
+
None, spacing_y)
|
|
598
|
+
|
|
599
|
+
return {"widths": width_lists, "heights": height_lists}
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
# ---------------------------------------------------------------------------
|
|
603
|
+
# arrange_legend_layout
|
|
604
|
+
# ---------------------------------------------------------------------------
|
|
605
|
+
|
|
606
|
+
def arrange_legend_layout(
|
|
607
|
+
n_breaks: int,
|
|
608
|
+
nrow: int,
|
|
609
|
+
ncol: int,
|
|
610
|
+
text_position: str = "right",
|
|
611
|
+
byrow: bool = False,
|
|
612
|
+
) -> Dict[str, List[int]]:
|
|
613
|
+
"""Compute cell positions for keys and labels in the legend gtable.
|
|
614
|
+
|
|
615
|
+
Mirrors ``GuideLegend$arrange_layout`` (guide-legend.R:503-531).
|
|
616
|
+
|
|
617
|
+
Parameters
|
|
618
|
+
----------
|
|
619
|
+
n_breaks, nrow, ncol : int
|
|
620
|
+
Number of breaks and legend grid dimensions.
|
|
621
|
+
text_position : str
|
|
622
|
+
"right", "left", "top", or "bottom".
|
|
623
|
+
byrow : bool
|
|
624
|
+
Fill by row?
|
|
625
|
+
|
|
626
|
+
Returns
|
|
627
|
+
-------
|
|
628
|
+
dict
|
|
629
|
+
``{"key_row": [...], "key_col": [...],
|
|
630
|
+
"label_row": [...], "label_col": [...]}``
|
|
631
|
+
1-based indices into the gtable.
|
|
632
|
+
"""
|
|
633
|
+
break_seq = list(range(1, n_breaks + 1))
|
|
634
|
+
|
|
635
|
+
if byrow:
|
|
636
|
+
row = [math.ceil(b / ncol) for b in break_seq]
|
|
637
|
+
col = [((b - 1) % ncol) + 1 for b in break_seq]
|
|
638
|
+
else:
|
|
639
|
+
row = [((b - 1) % nrow) + 1 for b in break_seq]
|
|
640
|
+
col = [math.ceil(b / nrow) for b in break_seq]
|
|
641
|
+
|
|
642
|
+
# Account for spacing rows/cols in between keys (every other row/col is a gap)
|
|
643
|
+
key_row = [r * 2 - 1 for r in row]
|
|
644
|
+
key_col = [c * 2 - 1 for c in col]
|
|
645
|
+
|
|
646
|
+
# Offset for key-label gaps depending on text_position
|
|
647
|
+
if text_position == "right":
|
|
648
|
+
key_col = [kc + (c - 1) for kc, c in zip(key_col, col)]
|
|
649
|
+
lab_col = [kc + 1 for kc in key_col]
|
|
650
|
+
lab_row = list(key_row)
|
|
651
|
+
elif text_position == "left":
|
|
652
|
+
key_col = [kc + c for kc, c in zip(key_col, col)]
|
|
653
|
+
lab_col = [kc - 1 for kc in key_col]
|
|
654
|
+
lab_row = list(key_row)
|
|
655
|
+
elif text_position == "top":
|
|
656
|
+
key_row = [kr + r for kr, r in zip(key_row, row)]
|
|
657
|
+
lab_row = [kr - 1 for kr in key_row]
|
|
658
|
+
lab_col = list(key_col)
|
|
659
|
+
elif text_position == "bottom":
|
|
660
|
+
key_row = [kr + (r - 1) for kr, r in zip(key_row, row)]
|
|
661
|
+
lab_row = [kr + 1 for kr in key_row]
|
|
662
|
+
lab_col = list(key_col)
|
|
663
|
+
else:
|
|
664
|
+
# Default to "right"
|
|
665
|
+
key_col = [kc + (c - 1) for kc, c in zip(key_col, col)]
|
|
666
|
+
lab_col = [kc + 1 for kc in key_col]
|
|
667
|
+
lab_row = list(key_row)
|
|
668
|
+
|
|
669
|
+
return {
|
|
670
|
+
"key_row": key_row,
|
|
671
|
+
"key_col": key_col,
|
|
672
|
+
"label_row": lab_row,
|
|
673
|
+
"label_col": lab_col,
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
# ---------------------------------------------------------------------------
|
|
678
|
+
# assemble_legend
|
|
679
|
+
# ---------------------------------------------------------------------------
|
|
680
|
+
|
|
681
|
+
def assemble_legend(
|
|
682
|
+
decor: List[Any],
|
|
683
|
+
labels: List[Any],
|
|
684
|
+
title_grob: Any,
|
|
685
|
+
layout: Dict[str, List[int]],
|
|
686
|
+
sizes: Dict[str, List[float]],
|
|
687
|
+
title_position: str = "top",
|
|
688
|
+
padding_cm: float = _DEFAULT_PADDING_CM,
|
|
689
|
+
bg_colour: Optional[str] = "white",
|
|
690
|
+
) -> Gtable:
|
|
691
|
+
"""Assemble a complete legend as a Gtable.
|
|
692
|
+
|
|
693
|
+
Mirrors ``GuideLegend$assemble_drawing`` (guide-legend.R:533-591)
|
|
694
|
+
plus ``Guide$add_title`` (guide-.R:924-951).
|
|
695
|
+
|
|
696
|
+
Parameters
|
|
697
|
+
----------
|
|
698
|
+
decor : list of grob
|
|
699
|
+
Key glyphs from :func:`build_legend_decor`.
|
|
700
|
+
labels : list of grob
|
|
701
|
+
Label grobs from :func:`build_legend_labels`.
|
|
702
|
+
title_grob : grob
|
|
703
|
+
Legend title grob.
|
|
704
|
+
layout : dict
|
|
705
|
+
Cell positions from :func:`arrange_legend_layout`.
|
|
706
|
+
sizes : dict
|
|
707
|
+
Widths/heights from :func:`measure_legend_grobs`.
|
|
708
|
+
title_position : str
|
|
709
|
+
Where to place the title: "top", "right", "bottom", "left".
|
|
710
|
+
padding_cm : float
|
|
711
|
+
Padding around the legend in cm.
|
|
712
|
+
bg_colour : str or None
|
|
713
|
+
Background fill colour.
|
|
714
|
+
|
|
715
|
+
Returns
|
|
716
|
+
-------
|
|
717
|
+
Gtable
|
|
718
|
+
Self-contained legend gtable.
|
|
719
|
+
"""
|
|
720
|
+
widths = Unit(sizes["widths"], "cm")
|
|
721
|
+
heights = Unit(sizes["heights"], "cm")
|
|
722
|
+
|
|
723
|
+
gt = Gtable(widths=widths, heights=heights, name="legend")
|
|
724
|
+
|
|
725
|
+
# Add key glyphs
|
|
726
|
+
if decor:
|
|
727
|
+
for idx, grob in enumerate(decor):
|
|
728
|
+
kr = layout["key_row"][idx]
|
|
729
|
+
kc = layout["key_col"][idx]
|
|
730
|
+
gt = gtable_add_grob(
|
|
731
|
+
gt, grob,
|
|
732
|
+
t=kr, l=kc, b=kr, r=kc,
|
|
733
|
+
clip="off",
|
|
734
|
+
name=f"key-{kr}-{kc}",
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
# Add labels
|
|
738
|
+
if labels:
|
|
739
|
+
for idx, grob in enumerate(labels):
|
|
740
|
+
lr = layout["label_row"][idx]
|
|
741
|
+
lc = layout["label_col"][idx]
|
|
742
|
+
gt = gtable_add_grob(
|
|
743
|
+
gt, grob,
|
|
744
|
+
t=lr, l=lc, b=lr, r=lc,
|
|
745
|
+
clip="off",
|
|
746
|
+
name=f"label-{lr}-{lc}",
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
# Add title (mirrors Guide$add_title, guide-.R:924-951)
|
|
750
|
+
gt = add_legend_title(gt, title_grob, title_position)
|
|
751
|
+
|
|
752
|
+
# Add padding (mirrors gtable_add_padding)
|
|
753
|
+
pad = Unit([padding_cm] * 4, "cm")
|
|
754
|
+
gt = gtable_add_padding(gt, pad)
|
|
755
|
+
|
|
756
|
+
# Add background
|
|
757
|
+
if bg_colour is not None:
|
|
758
|
+
bg = rect_grob(
|
|
759
|
+
gp=Gpar(fill=bg_colour, col="grey85", lwd=0.5),
|
|
760
|
+
name="legend.background",
|
|
761
|
+
)
|
|
762
|
+
nrow_gt = gt.nrow
|
|
763
|
+
ncol_gt = gt.ncol
|
|
764
|
+
gt = gtable_add_grob(
|
|
765
|
+
gt, bg,
|
|
766
|
+
t=1, l=1, b=nrow_gt, r=ncol_gt,
|
|
767
|
+
z=-math.inf,
|
|
768
|
+
clip="off",
|
|
769
|
+
name="background",
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
return gt
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
# ---------------------------------------------------------------------------
|
|
776
|
+
# add_legend_title
|
|
777
|
+
# ---------------------------------------------------------------------------
|
|
778
|
+
|
|
779
|
+
def add_legend_title(
|
|
780
|
+
gt: Gtable,
|
|
781
|
+
title_grob: Any,
|
|
782
|
+
position: str = "top",
|
|
783
|
+
hjust: float = 0.0,
|
|
784
|
+
vjust: float = 0.5,
|
|
785
|
+
) -> Gtable:
|
|
786
|
+
"""Add a title to a legend gtable.
|
|
787
|
+
|
|
788
|
+
When the title's rendered size exceeds the existing table along the
|
|
789
|
+
long axis, padding columns (rows) are inserted on one or both sides
|
|
790
|
+
so the title stays within the legend background. ``hjust`` / ``vjust``
|
|
791
|
+
control how the excess splits between the two sides.
|
|
792
|
+
|
|
793
|
+
Parameters
|
|
794
|
+
----------
|
|
795
|
+
gt : Gtable
|
|
796
|
+
Legend gtable under construction.
|
|
797
|
+
title_grob : grob
|
|
798
|
+
Title grob.
|
|
799
|
+
position : str
|
|
800
|
+
"top", "right", "bottom", or "left".
|
|
801
|
+
hjust, vjust : float
|
|
802
|
+
Title justification (0 = left/top, 1 = right/bottom).
|
|
803
|
+
|
|
804
|
+
Returns
|
|
805
|
+
-------
|
|
806
|
+
Gtable
|
|
807
|
+
With title added.
|
|
808
|
+
"""
|
|
809
|
+
if title_grob is None:
|
|
810
|
+
return gt
|
|
811
|
+
|
|
812
|
+
from grid_py import (
|
|
813
|
+
grob_height as _gh,
|
|
814
|
+
grob_width as _gw,
|
|
815
|
+
convert_width as _cw,
|
|
816
|
+
convert_height as _ch,
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
def _cm_total(u: Any, axis: str) -> float:
|
|
820
|
+
"""Resolve a (possibly composite) unit to a total cm value."""
|
|
821
|
+
try:
|
|
822
|
+
fn = _cw if axis == "x" else _ch
|
|
823
|
+
arr = fn(u, "cm", valueOnly=True)
|
|
824
|
+
return float(np.sum(arr))
|
|
825
|
+
except Exception:
|
|
826
|
+
return 0.0
|
|
827
|
+
|
|
828
|
+
if position == "top":
|
|
829
|
+
gt = gtable_add_rows(gt, _gh(title_grob), pos=0)
|
|
830
|
+
gt = gtable_add_grob(
|
|
831
|
+
gt, title_grob,
|
|
832
|
+
t=1, l=1, r=gt.ncol, b=1,
|
|
833
|
+
z=-math.inf, clip="off", name="title",
|
|
834
|
+
)
|
|
835
|
+
elif position == "bottom":
|
|
836
|
+
gt = gtable_add_rows(gt, _gh(title_grob), pos=-1)
|
|
837
|
+
gt = gtable_add_grob(
|
|
838
|
+
gt, title_grob,
|
|
839
|
+
t=gt.nrow, l=1, r=gt.ncol, b=gt.nrow,
|
|
840
|
+
z=-math.inf, clip="off", name="title",
|
|
841
|
+
)
|
|
842
|
+
elif position == "left":
|
|
843
|
+
gt = gtable_add_cols(gt, _gw(title_grob), pos=0)
|
|
844
|
+
gt = gtable_add_grob(
|
|
845
|
+
gt, title_grob,
|
|
846
|
+
t=1, l=1, r=1, b=gt.nrow,
|
|
847
|
+
z=-math.inf, clip="off", name="title",
|
|
848
|
+
)
|
|
849
|
+
elif position == "right":
|
|
850
|
+
gt = gtable_add_cols(gt, _gw(title_grob), pos=-1)
|
|
851
|
+
gt = gtable_add_grob(
|
|
852
|
+
gt, title_grob,
|
|
853
|
+
t=1, l=gt.ncol, r=gt.ncol, b=gt.nrow,
|
|
854
|
+
z=-math.inf, clip="off", name="title",
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
# If the title overflows the existing table along its orientation axis,
|
|
858
|
+
# pad both sides so it stays inside the legend background. The split
|
|
859
|
+
# between the two pad cells is controlled by hjust / vjust.
|
|
860
|
+
if position in ("top", "bottom"):
|
|
861
|
+
title_width_cm = _cm_total(_gw(title_grob), "x")
|
|
862
|
+
table_width_cm = sum(_cm_total(Unit(w, "cm") if isinstance(w, (int, float)) else w, "x")
|
|
863
|
+
for w in gt.widths) if hasattr(gt, "widths") else _cm_total(gt.widths, "x")
|
|
864
|
+
extra = max(0.0, title_width_cm - table_width_cm)
|
|
865
|
+
if extra > 1e-6:
|
|
866
|
+
left_pad = hjust * extra
|
|
867
|
+
right_pad = (1.0 - hjust) * extra
|
|
868
|
+
if left_pad > 1e-6:
|
|
869
|
+
gt = gtable_add_cols(gt, Unit(left_pad, "cm"), pos=0)
|
|
870
|
+
if right_pad > 1e-6:
|
|
871
|
+
gt = gtable_add_cols(gt, Unit(right_pad, "cm"), pos=-1)
|
|
872
|
+
else:
|
|
873
|
+
title_height_cm = _cm_total(_gh(title_grob), "y")
|
|
874
|
+
table_height_cm = sum(_cm_total(Unit(h, "cm") if isinstance(h, (int, float)) else h, "y")
|
|
875
|
+
for h in gt.heights) if hasattr(gt, "heights") else _cm_total(gt.heights, "y")
|
|
876
|
+
extra = max(0.0, title_height_cm - table_height_cm)
|
|
877
|
+
if extra > 1e-6:
|
|
878
|
+
top_pad = vjust * extra
|
|
879
|
+
bottom_pad = (1.0 - vjust) * extra
|
|
880
|
+
if top_pad > 1e-6:
|
|
881
|
+
gt = gtable_add_rows(gt, Unit(top_pad, "cm"), pos=0)
|
|
882
|
+
if bottom_pad > 1e-6:
|
|
883
|
+
gt = gtable_add_rows(gt, Unit(bottom_pad, "cm"), pos=-1)
|
|
884
|
+
|
|
885
|
+
return gt
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
# ---------------------------------------------------------------------------
|
|
889
|
+
# package_legend_box
|
|
890
|
+
# ---------------------------------------------------------------------------
|
|
891
|
+
|
|
892
|
+
def package_legend_box(
|
|
893
|
+
legends: List[Gtable],
|
|
894
|
+
position: str = "right",
|
|
895
|
+
spacing_cm: float = 0.2,
|
|
896
|
+
) -> Gtable:
|
|
897
|
+
"""Combine multiple legends into a single guide-box Gtable.
|
|
898
|
+
|
|
899
|
+
Mirrors ``Guides$package_box`` (guides-.R:592-757).
|
|
900
|
+
|
|
901
|
+
For ``position="right"`` or ``"left"`` (vertical), legends are stacked
|
|
902
|
+
in a ``gtable_col``. For ``"top"`` / ``"bottom"`` (horizontal), they
|
|
903
|
+
are placed side by side in a ``gtable_row``.
|
|
904
|
+
|
|
905
|
+
Parameters
|
|
906
|
+
----------
|
|
907
|
+
legends : list of Gtable
|
|
908
|
+
Individual legend gtables.
|
|
909
|
+
position : str
|
|
910
|
+
Legend box position relative to the plot.
|
|
911
|
+
spacing_cm : float
|
|
912
|
+
Spacing between legends in cm.
|
|
913
|
+
|
|
914
|
+
Returns
|
|
915
|
+
-------
|
|
916
|
+
Gtable
|
|
917
|
+
Combined guide-box.
|
|
918
|
+
"""
|
|
919
|
+
if not legends:
|
|
920
|
+
return Gtable(name="guide-box")
|
|
921
|
+
|
|
922
|
+
if len(legends) == 1:
|
|
923
|
+
legends[0].name = "guide-box"
|
|
924
|
+
return legends[0]
|
|
925
|
+
|
|
926
|
+
direction = "horizontal" if position in ("top", "bottom") else "vertical"
|
|
927
|
+
|
|
928
|
+
if direction == "vertical":
|
|
929
|
+
# Stack vertically
|
|
930
|
+
# Compute common width = max of all legends
|
|
931
|
+
max_width_cm = 0.0
|
|
932
|
+
heights_cm = []
|
|
933
|
+
for lg in legends:
|
|
934
|
+
w = _gtable_total_cm(lg.widths)
|
|
935
|
+
h = _gtable_total_cm(lg.heights)
|
|
936
|
+
max_width_cm = max(max_width_cm, w)
|
|
937
|
+
heights_cm.append(h)
|
|
938
|
+
|
|
939
|
+
guides = gtable_col(
|
|
940
|
+
name="guides",
|
|
941
|
+
grobs=legends,
|
|
942
|
+
width=Unit(max_width_cm, "cm"),
|
|
943
|
+
heights=Unit(heights_cm, "cm"),
|
|
944
|
+
)
|
|
945
|
+
guides = gtable_add_row_space(guides, Unit(spacing_cm, "cm"))
|
|
946
|
+
else:
|
|
947
|
+
# Place side by side
|
|
948
|
+
max_height_cm = 0.0
|
|
949
|
+
widths_cm = []
|
|
950
|
+
for lg in legends:
|
|
951
|
+
w = _gtable_total_cm(lg.widths)
|
|
952
|
+
h = _gtable_total_cm(lg.heights)
|
|
953
|
+
max_height_cm = max(max_height_cm, h)
|
|
954
|
+
widths_cm.append(w)
|
|
955
|
+
|
|
956
|
+
from gtable_py import gtable_row, gtable_add_col_space
|
|
957
|
+
guides = gtable_row(
|
|
958
|
+
name="guides",
|
|
959
|
+
grobs=legends,
|
|
960
|
+
height=Unit(max_height_cm, "cm"),
|
|
961
|
+
widths=Unit(widths_cm, "cm"),
|
|
962
|
+
)
|
|
963
|
+
guides = gtable_add_col_space(guides, Unit(spacing_cm, "cm"))
|
|
964
|
+
|
|
965
|
+
guides.name = "guide-box"
|
|
966
|
+
return guides
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
# ---------------------------------------------------------------------------
|
|
970
|
+
# Internal helpers
|
|
971
|
+
# ---------------------------------------------------------------------------
|
|
972
|
+
|
|
973
|
+
def _fill_matrix(
|
|
974
|
+
values: List[float], nrow: int, ncol: int, byrow: bool = False
|
|
975
|
+
) -> List[List[float]]:
|
|
976
|
+
"""Fill a flat list into a nrow x ncol matrix.
|
|
977
|
+
|
|
978
|
+
Parameters
|
|
979
|
+
----------
|
|
980
|
+
values : list of float
|
|
981
|
+
Flat values (length >= nrow * ncol).
|
|
982
|
+
nrow, ncol : int
|
|
983
|
+
Matrix dimensions.
|
|
984
|
+
byrow : bool
|
|
985
|
+
Fill by row if True, else by column (R default).
|
|
986
|
+
|
|
987
|
+
Returns
|
|
988
|
+
-------
|
|
989
|
+
list of list of float
|
|
990
|
+
``matrix[row][col]``.
|
|
991
|
+
"""
|
|
992
|
+
matrix = [[0.0] * ncol for _ in range(nrow)]
|
|
993
|
+
for idx, val in enumerate(values[: nrow * ncol]):
|
|
994
|
+
if byrow:
|
|
995
|
+
r = idx // ncol
|
|
996
|
+
c = idx % ncol
|
|
997
|
+
else:
|
|
998
|
+
r = idx % nrow
|
|
999
|
+
c = idx // nrow
|
|
1000
|
+
matrix[r][c] = val
|
|
1001
|
+
return matrix
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def _interleave(
|
|
1005
|
+
a: List[float],
|
|
1006
|
+
b: Optional[List[float]],
|
|
1007
|
+
gap: float,
|
|
1008
|
+
) -> List[float]:
|
|
1009
|
+
"""Interleave two lists with a gap, stripping the trailing gap.
|
|
1010
|
+
|
|
1011
|
+
If *b* is ``None``, just interleave *a* with *gap*.
|
|
1012
|
+
|
|
1013
|
+
Examples
|
|
1014
|
+
--------
|
|
1015
|
+
>>> _interleave([1, 2], [3, 4], 0.1)
|
|
1016
|
+
[1, 3, 0.1, 2, 4, 0.1] # then strip last → [1, 3, 0.1, 2, 4]
|
|
1017
|
+
"""
|
|
1018
|
+
result: List[float] = []
|
|
1019
|
+
if b is not None:
|
|
1020
|
+
for i in range(len(a)):
|
|
1021
|
+
result.append(a[i])
|
|
1022
|
+
result.append(b[i] if i < len(b) else 0.0)
|
|
1023
|
+
result.append(gap)
|
|
1024
|
+
else:
|
|
1025
|
+
for i in range(len(a)):
|
|
1026
|
+
result.append(a[i])
|
|
1027
|
+
result.append(gap)
|
|
1028
|
+
|
|
1029
|
+
# Strip trailing gap
|
|
1030
|
+
if result and result[-1] == gap:
|
|
1031
|
+
result = result[:-1]
|
|
1032
|
+
return result
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _gtable_total_cm(unit: Optional[Unit]) -> float:
|
|
1036
|
+
"""Sum a Unit vector, returning cm as a float.
|
|
1037
|
+
|
|
1038
|
+
Falls back to simple sum of values for "cm" units; returns a
|
|
1039
|
+
reasonable estimate for mixed/null units.
|
|
1040
|
+
"""
|
|
1041
|
+
if unit is None or len(unit) == 0:
|
|
1042
|
+
return 0.0
|
|
1043
|
+
total = 0.0
|
|
1044
|
+
for i in range(len(unit)):
|
|
1045
|
+
part = unit[i: i + 1]
|
|
1046
|
+
vals = part.values if hasattr(part, "values") else [0.0]
|
|
1047
|
+
units = part.units if hasattr(part, "units") else ["cm"]
|
|
1048
|
+
v = vals[0] if vals else 0.0
|
|
1049
|
+
u = units[0] if units else "cm"
|
|
1050
|
+
if u == "cm":
|
|
1051
|
+
total += v
|
|
1052
|
+
elif u == "mm":
|
|
1053
|
+
total += v / 10.0
|
|
1054
|
+
elif u == "inches":
|
|
1055
|
+
total += v * 2.54
|
|
1056
|
+
elif u == "pt" or u == "points":
|
|
1057
|
+
total += v / 72.27 * 2.54
|
|
1058
|
+
else:
|
|
1059
|
+
# null, npc, etc. — use the numeric value as a rough estimate
|
|
1060
|
+
total += v
|
|
1061
|
+
return total
|