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
ggplot2_py/guide_axis.py
ADDED
|
@@ -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")
|