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,615 @@
1
+ """
2
+ Axis guide rendering — faithful port of R's GuideAxis.
3
+
4
+ Builds axis grobs (line, ticks, labels) as a **gtable** so that
5
+ ``gtable_width()`` / ``gtable_height()`` return the correct measured
6
+ dimensions, eliminating hardcoded cm values and manual arithmetic.
7
+
8
+ R references
9
+ ------------
10
+ * ``ggplot2/R/guide-axis.R`` — GuideAxis class + draw_axis helper
11
+ * ``ggplot2/R/guide-.R`` — Guide$build_ticks base method
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from typing import Any, Dict, List, Optional, Tuple
18
+
19
+ import numpy as np
20
+
21
+ from grid_py import (
22
+ GList,
23
+ GTree,
24
+ Gpar,
25
+ Unit,
26
+ Viewport,
27
+ null_grob,
28
+ segments_grob,
29
+ text_grob,
30
+ unit_c,
31
+ grob_height,
32
+ grob_width,
33
+ )
34
+ from grid_py._grob import grob_tree
35
+
36
+ from gtable_py import (
37
+ Gtable,
38
+ gtable_add_cols,
39
+ gtable_add_grob,
40
+ gtable_add_rows,
41
+ gtable_height,
42
+ gtable_width,
43
+ )
44
+
45
+ __all__ = ["draw_axis"]
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Unit conversion constant (R: .pt = 72.27 / 25.4, mm → points)
50
+ # ---------------------------------------------------------------------------
51
+ _PT: float = 72.27 / 25.4
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Helpers
56
+ # ---------------------------------------------------------------------------
57
+
58
+ def _unit_to_cm(u: Unit) -> float:
59
+ """Convert a Unit (possibly compound/sum) to cm.
60
+
61
+ ``convert_height/width`` can't handle compound ``"sum"`` units
62
+ without a viewport context. This helper decomposes them into
63
+ leaf components, converts each individually, and sums the results.
64
+
65
+ Sum unit structure (from ``Unit.__add__``)::
66
+
67
+ _units = ['sum']
68
+ _values = [1.0]
69
+ _data = [Unit([2.75, 0.42], ['points', 'cm'])]
70
+
71
+ The ``data`` element is itself a multi-element Unit with the
72
+ individual operands.
73
+ """
74
+ from grid_py import convert_height
75
+
76
+ units = getattr(u, "_units", None)
77
+ values = getattr(u, "_values", None)
78
+ data = getattr(u, "_data", None)
79
+ if units is None or values is None:
80
+ return 0.0
81
+
82
+ total_cm = 0.0
83
+ n = len(u)
84
+ for i in range(n):
85
+ unit_type = units[i] if i < len(units) else "cm"
86
+
87
+ if unit_type == "sum":
88
+ # The operands are stored as a multi-element Unit in data[i]
89
+ inner = data[i] if data and i < len(data) else None
90
+ if inner is not None and isinstance(inner, Unit):
91
+ total_cm += _unit_to_cm(inner)
92
+ continue
93
+
94
+ # Skip context-dependent units we can't resolve statically
95
+ if unit_type in ("npc", "native", "null", "grobwidth", "grobheight",
96
+ "strwidth", "strheight"):
97
+ continue
98
+
99
+ val = float(values[i]) if i < len(values) else 0.0
100
+ leaf = Unit(val, unit_type)
101
+ try:
102
+ cm = convert_height(leaf, "cm", valueOnly=True)
103
+ total_cm += float(np.sum(cm))
104
+ except Exception:
105
+ pass
106
+
107
+ return total_cm
108
+
109
+
110
+ def _has_sum_unit(u: Unit) -> bool:
111
+ """Check if a Unit contains any ``"sum"`` type components."""
112
+ units = getattr(u, "_units", None)
113
+ return units is not None and "sum" in units
114
+
115
+
116
+ def _width_cm(x: Any) -> float:
117
+ """Measure a grob or unit width in cm (R: utilities-grid.R:67-76)."""
118
+ from grid_py import convert_width
119
+ if hasattr(x, "width_details") and callable(x.width_details):
120
+ u = x.width_details()
121
+ elif isinstance(x, Unit):
122
+ u = x
123
+ else:
124
+ return 0.0
125
+ # For compound (sum) units, convert_width returns bogus results;
126
+ # decompose and convert leaf-by-leaf instead.
127
+ if _has_sum_unit(u):
128
+ return _unit_to_cm(u)
129
+ try:
130
+ result = convert_width(u, "cm", valueOnly=True)
131
+ return float(np.sum(result))
132
+ except Exception:
133
+ return _unit_to_cm(u)
134
+
135
+
136
+ def _height_cm(x: Any) -> float:
137
+ """Measure a grob or unit height in cm (R: utilities-grid.R:78-88)."""
138
+ from grid_py import convert_height
139
+ if hasattr(x, "height_details") and callable(x.height_details):
140
+ u = x.height_details()
141
+ elif isinstance(x, Unit):
142
+ u = x
143
+ else:
144
+ return 0.0
145
+ if _has_sum_unit(u):
146
+ return _unit_to_cm(u)
147
+ try:
148
+ result = convert_height(u, "cm", valueOnly=True)
149
+ return float(np.sum(result))
150
+ except Exception:
151
+ return _unit_to_cm(u)
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # draw_axis — main entry point (mirrors R's draw_axis, guide-axis.R:508-529)
156
+ # ---------------------------------------------------------------------------
157
+
158
+ def draw_axis(
159
+ break_positions: Any,
160
+ break_labels: List[str],
161
+ axis_position: str,
162
+ theme: Any,
163
+ check_overlap: bool = False,
164
+ angle: Optional[float] = None,
165
+ n_dodge: int = 1,
166
+ minor_ticks: bool = False,
167
+ minor_positions: Optional[Any] = None,
168
+ cap: str = "none",
169
+ ) -> Any:
170
+ """Build a complete axis grob as a **gtable**.
171
+
172
+ Mirrors R's ``draw_axis()`` (guide-axis.R:508-529) and the
173
+ ``GuideAxis$assemble_drawing()`` method (guide-axis.R:420-474)
174
+ which constructs a properly-measured gtable with tick, label,
175
+ and title components.
176
+
177
+ Parameters
178
+ ----------
179
+ break_positions : array-like
180
+ Major break positions in [0, 1] NPC.
181
+ break_labels : list of str
182
+ Labels for each major break.
183
+ axis_position : str
184
+ One of ``"top"``, ``"bottom"``, ``"left"``, ``"right"``.
185
+ theme : Theme
186
+ Plot theme.
187
+ check_overlap : bool
188
+ Silently remove overlapping labels.
189
+ angle : float or None
190
+ Label rotation angle in degrees.
191
+ n_dodge : int
192
+ Number of rows/columns for dodging labels.
193
+ minor_ticks : bool
194
+ Whether to draw minor tick marks.
195
+ minor_positions : array-like or None
196
+ Minor break positions in [0, 1] NPC.
197
+ cap : str
198
+ Axis line cap style: ``"none"``, ``"both"``, ``"upper"``, ``"lower"``.
199
+
200
+ Returns
201
+ -------
202
+ Gtable
203
+ An axis gtable containing line, ticks, and labels, with
204
+ proper widths/heights for measurement via ``gtable_width()``
205
+ / ``gtable_height()``.
206
+ """
207
+ from ggplot2_py.theme_elements import calc_element, element_render, _PT
208
+
209
+ breaks = np.asarray(break_positions, dtype=float) if break_positions is not None else np.array([])
210
+ if len(breaks) == 0:
211
+ return null_grob()
212
+
213
+ if len(break_labels) != len(breaks):
214
+ break_labels = [str(round(b, 2)) for b in breaks]
215
+
216
+ # --- Setup params (R: GuideAxis$setup_params, lines 275-306) ----------
217
+ is_horizontal = axis_position in ("top", "bottom")
218
+ is_vertical = not is_horizontal
219
+ aes = "x" if is_horizontal else "y"
220
+ orth_aes = "y" if is_horizontal else "x"
221
+ is_secondary = axis_position in ("top", "right")
222
+ opposite = {"top": "bottom", "bottom": "top",
223
+ "left": "right", "right": "left"}[axis_position]
224
+ orth_side = 0.0 if is_secondary else 1.0
225
+ lab_first = axis_position in ("top", "left")
226
+
227
+ # --- Resolve theme elements ----------------------------------------
228
+ # Use calc_element for proper inheritance resolution.
229
+ line_el = _resolve_el(f"axis.line.{aes}", theme,
230
+ fallback={"colour": "grey20", "linewidth": 0.5, "linetype": 1})
231
+ tick_el = _resolve_el(f"axis.ticks.{aes}", theme,
232
+ fallback={"colour": "grey20", "linewidth": 0.5})
233
+ text_el = _resolve_el(f"axis.text.{aes}", theme,
234
+ fallback={"colour": "grey30", "size": 8, "angle": 0,
235
+ "hjust": None, "vjust": None})
236
+
237
+ # Tick length from theme (R: elements$major_length / minor_length)
238
+ # R theme default: axis.ticks.length = unit(2.75, "pt")
239
+ tick_length = _resolve_tick_length(theme, aes)
240
+ minor_tick_length = tick_length * 0.5 # R: axis.minor.ticks.length = rel(0.75)
241
+
242
+ # --- Build axis line (R: GuideAxis$build_decor, lines 313-322) -----
243
+ if cap == "none" or len(breaks) == 0:
244
+ line_start, line_end = 0.0, 1.0
245
+ else:
246
+ line_start = min(breaks) if cap in ("both", "lower") else 0.0
247
+ line_end = max(breaks) if cap in ("both", "upper") else 1.0
248
+
249
+ line_lwd = float(line_el.get("linewidth", 0.5)) * _PT
250
+ if is_horizontal:
251
+ axis_line = segments_grob(
252
+ x0=[line_start], y0=[orth_side],
253
+ x1=[line_end], y1=[orth_side],
254
+ gp=Gpar(col=line_el.get("colour", "grey20"), lwd=line_lwd),
255
+ name="axis.line",
256
+ )
257
+ else:
258
+ axis_line = segments_grob(
259
+ x0=[orth_side], y0=[line_start],
260
+ x1=[orth_side], y1=[line_end],
261
+ gp=Gpar(col=line_el.get("colour", "grey20"), lwd=line_lwd),
262
+ name="axis.line",
263
+ )
264
+
265
+ # --- Build tick marks (R: GuideAxis$build_ticks, lines 324-342) ----
266
+ tick_sign = -1.0 if axis_position in ("bottom", "left") else 1.0
267
+ tick_lwd = float(tick_el.get("linewidth", 0.5)) * _PT
268
+ tick_col = tick_el.get("colour", "grey20")
269
+
270
+ if is_horizontal:
271
+ major_ticks = segments_grob(
272
+ x0=breaks.tolist(),
273
+ y0=[orth_side] * len(breaks),
274
+ x1=breaks.tolist(),
275
+ y1=[orth_side + tick_sign * tick_length] * len(breaks),
276
+ gp=Gpar(col=tick_col, lwd=tick_lwd),
277
+ name="axis.ticks.major",
278
+ )
279
+ else:
280
+ major_ticks = segments_grob(
281
+ x0=[orth_side] * len(breaks),
282
+ y0=breaks.tolist(),
283
+ x1=[orth_side + tick_sign * tick_length] * len(breaks),
284
+ y1=breaks.tolist(),
285
+ gp=Gpar(col=tick_col, lwd=tick_lwd),
286
+ name="axis.ticks.major",
287
+ )
288
+
289
+ ticks_grob = major_ticks
290
+
291
+ # Minor ticks (R: lines 332-341)
292
+ if minor_ticks and minor_positions is not None:
293
+ minor_pos = np.asarray(minor_positions, dtype=float)
294
+ minor_pos = np.array([p for p in minor_pos if p not in breaks])
295
+ if len(minor_pos) > 0:
296
+ if is_horizontal:
297
+ minor_grob = segments_grob(
298
+ x0=minor_pos.tolist(),
299
+ y0=[orth_side] * len(minor_pos),
300
+ x1=minor_pos.tolist(),
301
+ y1=[orth_side + tick_sign * minor_tick_length] * len(minor_pos),
302
+ gp=Gpar(col=tick_col, lwd=tick_lwd * 0.5),
303
+ name="axis.ticks.minor",
304
+ )
305
+ else:
306
+ minor_grob = segments_grob(
307
+ x0=[orth_side] * len(minor_pos),
308
+ y0=minor_pos.tolist(),
309
+ x1=[orth_side + tick_sign * minor_tick_length] * len(minor_pos),
310
+ y1=minor_pos.tolist(),
311
+ gp=Gpar(col=tick_col, lwd=tick_lwd * 0.5),
312
+ name="axis.ticks.minor",
313
+ )
314
+ ticks_grob = grob_tree(major_ticks, minor_grob, name="axis.ticks")
315
+
316
+ # --- Build labels (R: GuideAxis$build_labels + draw_axis_labels) ---
317
+ # Route through element_render so hjust/vjust/margin/angle from the
318
+ # axis.text element drive positioning — matching R guide-axis.R:531-553
319
+ # element_grob(element_text, <pos_dim>=breaks, margin_x/y=TRUE, label=...)
320
+ # This ensures:
321
+ # * default x (vertical) / y (horizontal) come from ``rotate_just``
322
+ # (left-axis labels: x=1npc hjust=1 → right-aligned against tick)
323
+ # * the titleGrob margin offset is applied so labels sit slightly
324
+ # inside the cell edge (no clipping on the outside)
325
+ fontsize = float(text_el.get("size", 8))
326
+
327
+ # Override angle (R: override_elements, guide-axis.R:263-265)
328
+ if angle is not None:
329
+ rot = float(angle)
330
+ elif text_el.get("angle") is not None and float(text_el["angle"]) != 0:
331
+ rot = float(text_el["angle"])
332
+ else:
333
+ rot = 0.0
334
+
335
+ # N-dodge: split labels across groups (R: guide-axis.R:359-371)
336
+ label_grobs = []
337
+ dodge_groups = [[] for _ in range(n_dodge)]
338
+ for i in range(len(breaks)):
339
+ dodge_groups[i % n_dodge].append(i)
340
+
341
+ el_name = f"axis.text.{aes}.{axis_position}"
342
+ for dodge_idx, indices in enumerate(dodge_groups):
343
+ if not indices:
344
+ continue
345
+
346
+ dodge_breaks = [float(breaks[i]) for i in indices]
347
+ dodge_labels = [str(break_labels[i]) for i in indices]
348
+
349
+ render_kwargs: Dict[str, Any] = {
350
+ "label": dodge_labels,
351
+ "size": fontsize,
352
+ }
353
+ if angle is not None:
354
+ render_kwargs["angle"] = rot
355
+
356
+ if is_horizontal:
357
+ render_kwargs["x"] = Unit(dodge_breaks, "npc")
358
+ render_kwargs["margin_y"] = True
359
+ else:
360
+ render_kwargs["y"] = Unit(dodge_breaks, "npc")
361
+ render_kwargs["margin_x"] = True
362
+
363
+ grob = element_render(theme, el_name, **render_kwargs)
364
+ # Wrap in a GTree so make_content can place it via the gtable layout
365
+ label_grobs.append(GTree(
366
+ children=GList(grob),
367
+ name=f"axis.labels.{dodge_idx}",
368
+ ))
369
+
370
+ # --- Measure components (R: GuideAxis$measure_grobs, lines 373-402) -
371
+ # R: labels <- unit(measure(grobs$labels), "cm")
372
+ # R: measure = height_cm for horizontal, width_cm for vertical
373
+ # R: the labels are titleGrobs with margin, so grobHeight includes margin.
374
+ #
375
+ # We measure using calc_string_metric + axis text margin from theme.
376
+ from grid_py._size import calc_string_metric
377
+ from ggplot2_py.theme_elements import calc_element as _calc_el
378
+
379
+ label_gp = Gpar(fontsize=fontsize)
380
+ max_label_w_in = 0.0
381
+ max_label_h_in = 0.0
382
+ for lbl in break_labels:
383
+ m = calc_string_metric(str(lbl), label_gp)
384
+ max_label_w_in = max(max_label_w_in, m["width"])
385
+ # R grobHeight for text = ascent + descent (whole glyph box)
386
+ max_label_h_in = max(max_label_h_in, m["ascent"] + m["descent"])
387
+
388
+ # Font descent for titleGrob height adjustment (R: margins.R:115-132)
389
+ descent_in = 0.0
390
+ for lbl in break_labels:
391
+ m = calc_string_metric(str(lbl), label_gp)
392
+ descent_in = max(descent_in, m["descent"])
393
+
394
+ # Account for rotation (R: margins.R:126-132)
395
+ rad = math.radians(abs(rot) % 360) if rot != 0 else 0.0
396
+ y_descent = abs(math.cos(rad)) * descent_in if rad != 0 else descent_in
397
+
398
+ # Projected height/width for rotated text
399
+ if is_horizontal and rot != 0:
400
+ proj_h = (max_label_w_in * abs(math.sin(rad))
401
+ + max_label_h_in * abs(math.cos(rad)))
402
+ else:
403
+ proj_h = max_label_h_in
404
+
405
+ # Add font descent (R: titleGrob adds this)
406
+ proj_h += y_descent
407
+ proj_h *= n_dodge # multiple dodge rows
408
+
409
+ # Add axis text margin from theme (R: axis.text.x/y has margin)
410
+ text_margin_cm = 0.0
411
+ text_theme_el = _calc_el(f"axis.text.{aes}", theme)
412
+ if text_theme_el is not None:
413
+ margin_obj = getattr(text_theme_el, "margin", None)
414
+ if margin_obj is not None:
415
+ from ggplot2_py.theme_elements import Margin
416
+ if isinstance(margin_obj, Margin):
417
+ # For horizontal: margin_y = TRUE, so add top + bottom margin
418
+ # For vertical: margin_x = TRUE, so add left + right margin
419
+ if is_horizontal:
420
+ # Convert pt to cm: 1pt = 1/72.27 inch = 1/72.27*2.54 cm
421
+ text_margin_cm = (margin_obj.t + margin_obj.b) / 72.27 * 2.54
422
+ else:
423
+ text_margin_cm = (margin_obj.l + margin_obj.r) / 72.27 * 2.54
424
+
425
+ # Convert measurements to Units for gtable construction
426
+ tick_size = _resolve_tick_length_unit(theme, aes)
427
+
428
+ if is_horizontal:
429
+ label_size_cm = proj_h * 2.54 + text_margin_cm # inches → cm + margin
430
+ else:
431
+ label_size_cm = max_label_w_in * 2.54 + text_margin_cm
432
+ label_size = Unit(label_size_cm, "cm")
433
+
434
+ # --- Assemble gtable (R: GuideAxis$assemble_drawing, lines 420-474) -
435
+ # Build the orthogonal dimension sizes.
436
+ # R: sizes = unit.c(tick_length, spacer, labels, title)
437
+ # We omit the title (standalone axis has no title in this path).
438
+ # Order: [ticks, labels] or [labels, ticks] depending on lab_first.
439
+ if lab_first:
440
+ sizes = unit_c(label_size, tick_size)
441
+ tick_pos = 2 # 1-indexed: tick is in column/row 2
442
+ label_pos = 1
443
+ else:
444
+ sizes = unit_c(tick_size, label_size)
445
+ tick_pos = 1
446
+ label_pos = 2
447
+
448
+ # Create the gtable with proper dimensions
449
+ if is_horizontal:
450
+ # Horizontal axis: widths = 1npc (full panel), heights = sizes
451
+ gt = Gtable(
452
+ widths=Unit(1, "npc"),
453
+ heights=sizes,
454
+ name=f"axis-{axis_position}",
455
+ )
456
+ # Add ticks
457
+ gt = gtable_add_grob(gt, ticks_grob,
458
+ t=tick_pos, l=1, clip="off",
459
+ name="axis.ticks")
460
+ # Add label grobs (one per dodge level)
461
+ for lg in label_grobs:
462
+ gt = gtable_add_grob(gt, lg,
463
+ t=label_pos, l=1, clip="off",
464
+ name="axis.labels")
465
+ else:
466
+ # Vertical axis: widths = sizes, heights = 1npc (full panel)
467
+ gt = Gtable(
468
+ widths=sizes,
469
+ heights=Unit(1, "npc"),
470
+ name=f"axis-{axis_position}",
471
+ )
472
+ # Add ticks
473
+ gt = gtable_add_grob(gt, ticks_grob,
474
+ t=1, l=tick_pos, clip="off",
475
+ name="axis.ticks")
476
+ # Add label grobs
477
+ for lg in label_grobs:
478
+ gt = gtable_add_grob(gt, lg,
479
+ t=1, l=label_pos, clip="off",
480
+ name="axis.labels")
481
+
482
+ # --- Create justification viewport (R: guide-axis.R:444-450) --------
483
+ # The viewport positions the axis+line pair at the correct panel edge.
484
+ # R attaches the vp to the ``absoluteGrob`` *wrapper* (not to the
485
+ # inner gtable), so both the axis_line and the gtable share a single
486
+ # transform. Attaching to the inner gt here caused the axis to
487
+ # overflow its cell and occlude xlab/ylab (observed during gallery
488
+ # validation).
489
+ if is_horizontal:
490
+ vp = Viewport(
491
+ y=Unit(orth_side, "npc"),
492
+ height=gtable_height(gt),
493
+ just=opposite,
494
+ )
495
+ else:
496
+ vp = Viewport(
497
+ x=Unit(orth_side, "npc"),
498
+ width=gtable_width(gt),
499
+ just=opposite,
500
+ )
501
+
502
+ # --- Wrap with axis line (R: absoluteGrob pattern, lines 468-473) ---
503
+ # The axis line sits on top of the gtable in an absoluteGrob-like
504
+ # wrapper that reports the gtable's dimensions and carries the vp.
505
+ result = _AbsoluteAxisGrob(
506
+ children=GList(axis_line, gt),
507
+ width=gtable_width(gt),
508
+ height=gtable_height(gt),
509
+ name=f"axis-{axis_position}",
510
+ )
511
+ result.vp = vp
512
+
513
+ # Backward compatibility: store _width_cm / _height_cm for callers
514
+ # that haven't been updated yet.
515
+ if is_horizontal:
516
+ result._height_cm = _height_cm(gtable_height(gt))
517
+ result._width_cm = None
518
+ else:
519
+ result._width_cm = _width_cm(gtable_width(gt))
520
+ result._height_cm = None
521
+
522
+ return result
523
+
524
+
525
+ # ---------------------------------------------------------------------------
526
+ # AbsoluteAxisGrob — equivalent of R's absoluteGrob
527
+ # ---------------------------------------------------------------------------
528
+
529
+ class _AbsoluteAxisGrob(GTree):
530
+ """A GTree wrapper that reports fixed width/height for measurement.
531
+
532
+ Mirrors R's ``absoluteGrob()`` (grid/R/grob.R) used by GuideAxis
533
+ to wrap the axis line + gtable with known dimensions.
534
+ """
535
+
536
+ def __init__(self, children: GList, width: Unit, height: Unit,
537
+ name: str = "absolute") -> None:
538
+ super().__init__(children=children, name=name)
539
+ self._abs_width = width
540
+ self._abs_height = height
541
+
542
+ def width_details(self) -> Unit:
543
+ return self._abs_width
544
+
545
+ def height_details(self) -> Unit:
546
+ return self._abs_height
547
+
548
+
549
+ # ---------------------------------------------------------------------------
550
+ # Theme element resolution helpers
551
+ # ---------------------------------------------------------------------------
552
+
553
+ def _resolve_el(element_name: str, theme: Any,
554
+ fallback: Dict[str, Any]) -> Dict[str, Any]:
555
+ """Resolve a theme element to a dict of properties.
556
+
557
+ Tries ``calc_element`` first; falls back to a static dict.
558
+ """
559
+ from ggplot2_py.theme_elements import calc_element
560
+ el = calc_element(element_name, theme)
561
+ if el is not None and not _is_blank(el):
562
+ result = {}
563
+ for key in fallback:
564
+ val = getattr(el, key, None)
565
+ if val is not None:
566
+ result[key] = val
567
+ else:
568
+ result[key] = fallback[key]
569
+ return result
570
+ return dict(fallback)
571
+
572
+
573
+ def _is_blank(el: Any) -> bool:
574
+ """Check if an element is ElementBlank."""
575
+ return getattr(el, "__class__", None).__name__ == "ElementBlank"
576
+
577
+
578
+ def _resolve_tick_length(theme: Any, aes: str) -> float:
579
+ """Get tick length in NPC units from theme.
580
+
581
+ R: ``axis.ticks.length`` defaults to ``unit(2.75, "pt")``.
582
+ Returns a float in NPC for positioning (approximate).
583
+ """
584
+ from ggplot2_py.theme_elements import calc_element
585
+ # Try to get the theme's tick length
586
+ el = None
587
+ for name in [f"axis.ticks.length.{aes}", "axis.ticks.length"]:
588
+ el = calc_element(name, theme)
589
+ if el is not None:
590
+ break
591
+ if el is not None and isinstance(el, Unit):
592
+ # Convert to approximate NPC (assuming ~400pt panel = ~14cm)
593
+ try:
594
+ from grid_py import convert_height
595
+ cm_val = convert_height(el, "cm", valueOnly=True)
596
+ return float(np.sum(cm_val)) / 14.0 # rough NPC
597
+ except Exception:
598
+ pass
599
+ # Default: 2.75pt ≈ 0.097cm → ~0.007 of a 14cm panel
600
+ # Use a sensible NPC default
601
+ return 0.03
602
+
603
+
604
+ def _resolve_tick_length_unit(theme: Any, aes: str) -> Unit:
605
+ """Get tick length as a proper Unit from theme.
606
+
607
+ R: ``axis.ticks.length`` defaults to ``unit(2.75, "pt")``.
608
+ """
609
+ from ggplot2_py.theme_elements import calc_element
610
+ for name in [f"axis.ticks.length.{aes}", "axis.ticks.length"]:
611
+ el = calc_element(name, theme)
612
+ if el is not None and isinstance(el, Unit):
613
+ return el
614
+ # Default: 2.75 pt (R's default)
615
+ return Unit(2.75, "points")