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,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