rgrid-python 4.5.3__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.
- grid_py/__init__.py +340 -0
- grid_py/_arrow.py +331 -0
- grid_py/_clippath.py +170 -0
- grid_py/_colour.py +815 -0
- grid_py/_coords.py +1534 -0
- grid_py/_curve.py +1668 -0
- grid_py/_display_list.py +507 -0
- grid_py/_draw.py +1397 -0
- grid_py/_edit.py +756 -0
- grid_py/_font_metrics.py +319 -0
- grid_py/_gpar.py +572 -0
- grid_py/_grab.py +501 -0
- grid_py/_grob.py +1377 -0
- grid_py/_group.py +798 -0
- grid_py/_highlevel.py +2176 -0
- grid_py/_just.py +361 -0
- grid_py/_layout.py +593 -0
- grid_py/_ls.py +895 -0
- grid_py/_mask.py +196 -0
- grid_py/_path.py +414 -0
- grid_py/_patterns.py +1049 -0
- grid_py/_primitives.py +2198 -0
- grid_py/_renderer_base.py +1184 -0
- grid_py/_scene_graph.py +248 -0
- grid_py/_size.py +1352 -0
- grid_py/_state.py +683 -0
- grid_py/_transforms.py +448 -0
- grid_py/_typeset.py +384 -0
- grid_py/_units.py +1924 -0
- grid_py/_utils.py +310 -0
- grid_py/_viewport.py +1649 -0
- grid_py/_vp_calc.py +970 -0
- grid_py/py.typed +0 -0
- grid_py/renderer.py +1762 -0
- grid_py/renderer_web.py +764 -0
- grid_py/resources/d3.v7.min.js +2 -0
- grid_py/resources/gridpy.css +80 -0
- grid_py/resources/gridpy.js +813 -0
- rgrid_python-4.5.3.dist-info/METADATA +489 -0
- rgrid_python-4.5.3.dist-info/RECORD +42 -0
- rgrid_python-4.5.3.dist-info/WHEEL +4 -0
- rgrid_python-4.5.3.dist-info/licenses/LICENSE +3 -0
grid_py/_draw.py
ADDED
|
@@ -0,0 +1,1397 @@
|
|
|
1
|
+
"""Core drawing engine for grid_py -- Python port of R's grid drawing functions.
|
|
2
|
+
|
|
3
|
+
This module handles rendering grobs via a :class:`GridRenderer` backend, porting
|
|
4
|
+
functionality from R's ``grid/R/grid.R`` and ``grid/R/grob.R``.
|
|
5
|
+
|
|
6
|
+
The central entry point is :func:`grid_draw`, which performs S3-like dispatch
|
|
7
|
+
on grobs, gTrees, gLists, viewports, and viewport paths.
|
|
8
|
+
|
|
9
|
+
References
|
|
10
|
+
----------
|
|
11
|
+
R source: ``src/library/grid/R/grid.R``, ``src/library/grid/R/grob.R``
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import copy
|
|
17
|
+
import warnings
|
|
18
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from ._gpar import Gpar
|
|
23
|
+
from ._grob import Grob, GList, GTree
|
|
24
|
+
from ._state import get_state
|
|
25
|
+
from ._display_list import DisplayList, DLDrawGrob
|
|
26
|
+
from ._units import Unit
|
|
27
|
+
from ._utils import grid_pretty as _grid_pretty
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"grid_draw",
|
|
31
|
+
"grid_newpage",
|
|
32
|
+
"grid_refresh",
|
|
33
|
+
"grid_record",
|
|
34
|
+
"record_grob",
|
|
35
|
+
"grid_delay",
|
|
36
|
+
"delay_grob",
|
|
37
|
+
"grid_dl_apply",
|
|
38
|
+
"grid_locator",
|
|
39
|
+
"grid_pretty",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Internal rendering helpers
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_HJUST_MAP = {"left": 0.0, "right": 1.0, "centre": 0.5, "center": 0.5}
|
|
49
|
+
_VJUST_MAP = {"bottom": 0.0, "top": 1.0, "centre": 0.5, "center": 0.5}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _resolve_just(grob: Any) -> Tuple[float, float]:
|
|
53
|
+
"""Resolve hjust/vjust from a grob, honouring the ``just`` attribute.
|
|
54
|
+
|
|
55
|
+
Matches R's ``valid.just()``: a single string like ``"right"`` sets
|
|
56
|
+
only the horizontal component (vjust defaults to 0.5), and
|
|
57
|
+
``"top"`` sets only the vertical component (hjust defaults to 0.5).
|
|
58
|
+
A 2-element vector ``("left", "top")`` sets both.
|
|
59
|
+
"""
|
|
60
|
+
hjust = getattr(grob, "hjust", None)
|
|
61
|
+
vjust = getattr(grob, "vjust", None)
|
|
62
|
+
if hjust is not None and vjust is not None:
|
|
63
|
+
return float(hjust), float(vjust)
|
|
64
|
+
just = getattr(grob, "just", None)
|
|
65
|
+
if just is not None:
|
|
66
|
+
if isinstance(just, str):
|
|
67
|
+
# Single string: "left"/"right" → hjust only;
|
|
68
|
+
# "top"/"bottom" → vjust only; "centre" → both
|
|
69
|
+
if just in _HJUST_MAP:
|
|
70
|
+
hj = _HJUST_MAP[just]
|
|
71
|
+
vj = _VJUST_MAP.get(just, 0.5)
|
|
72
|
+
elif just in _VJUST_MAP:
|
|
73
|
+
hj = _HJUST_MAP.get(just, 0.5)
|
|
74
|
+
vj = _VJUST_MAP[just]
|
|
75
|
+
else:
|
|
76
|
+
hj, vj = 0.5, 0.5
|
|
77
|
+
elif isinstance(just, (list, tuple)) and len(just) >= 2:
|
|
78
|
+
hj = _HJUST_MAP.get(just[0], 0.5) if isinstance(just[0], str) else float(just[0])
|
|
79
|
+
vj = _VJUST_MAP.get(just[1], 0.5) if isinstance(just[1], str) else float(just[1])
|
|
80
|
+
elif isinstance(just, (list, tuple)) and len(just) == 1:
|
|
81
|
+
hj = _HJUST_MAP.get(just[0], 0.5) if isinstance(just[0], str) else float(just[0])
|
|
82
|
+
vj = 0.5
|
|
83
|
+
else:
|
|
84
|
+
hj, vj = 0.5, 0.5
|
|
85
|
+
if hjust is None:
|
|
86
|
+
hjust = hj
|
|
87
|
+
if vjust is None:
|
|
88
|
+
vjust = vj
|
|
89
|
+
return float(hjust if hjust is not None else 0.5), float(vjust if vjust is not None else 0.5)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _subset_gpar(gp: Optional[Gpar], i: int) -> Optional[Gpar]:
|
|
93
|
+
"""Return a Gpar containing only the *i*-th element of each vectorised param.
|
|
94
|
+
|
|
95
|
+
R ``NA`` semantics: when a vectorised colour/fill contains ``NA``
|
|
96
|
+
entries, the corresponding rect/line/text must render with no
|
|
97
|
+
stroke/fill — not fall back to the ``get.gpar()`` default. Python
|
|
98
|
+
represents ``NA`` as ``None`` in a sequence. Since
|
|
99
|
+
:class:`Gpar` drops ``None`` *scalars* at construction (matching
|
|
100
|
+
R's ``NULL`` semantics = "inherit"), we preserve the NA intent
|
|
101
|
+
here by emitting the string ``"transparent"`` for colour-typed
|
|
102
|
+
fields, which the renderer parses as a zero-alpha colour.
|
|
103
|
+
"""
|
|
104
|
+
if gp is None:
|
|
105
|
+
return None
|
|
106
|
+
new_params: Dict[str, Any] = {}
|
|
107
|
+
for key, val in gp.params.items():
|
|
108
|
+
if isinstance(val, np.ndarray) and val.ndim >= 1 and len(val) > 1:
|
|
109
|
+
picked = val[i % len(val)]
|
|
110
|
+
elif isinstance(val, (list, tuple)) and len(val) > 1:
|
|
111
|
+
picked = val[i % len(val)]
|
|
112
|
+
else:
|
|
113
|
+
picked = val
|
|
114
|
+
|
|
115
|
+
if picked is None and key in ("col", "fill") and val is not picked:
|
|
116
|
+
picked = "transparent"
|
|
117
|
+
new_params[key] = picked
|
|
118
|
+
return Gpar(**new_params)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _unit_to_float(val: Any) -> float:
|
|
122
|
+
"""Extract a scalar float from a value that may be a Unit.
|
|
123
|
+
|
|
124
|
+
.. deprecated::
|
|
125
|
+
Use ``renderer.resolve_x/y/w/h`` instead for unit-aware resolution.
|
|
126
|
+
This function is kept only for call sites that genuinely need a raw
|
|
127
|
+
numeric value without unit resolution (e.g. rotation angles).
|
|
128
|
+
"""
|
|
129
|
+
from ._units import Unit
|
|
130
|
+
if isinstance(val, Unit):
|
|
131
|
+
return float(val._values[0])
|
|
132
|
+
return float(val)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _unit_to_array(val: Any) -> np.ndarray:
|
|
136
|
+
"""Extract a numeric array from a value that may be a Unit.
|
|
137
|
+
|
|
138
|
+
.. deprecated::
|
|
139
|
+
Use ``renderer.resolve_x/y_array`` instead for unit-aware resolution.
|
|
140
|
+
"""
|
|
141
|
+
from ._units import Unit
|
|
142
|
+
if isinstance(val, Unit):
|
|
143
|
+
return np.asarray(val._values, dtype=float)
|
|
144
|
+
if isinstance(val, (list, tuple)):
|
|
145
|
+
try:
|
|
146
|
+
return np.asarray(val, dtype=float)
|
|
147
|
+
except (ValueError, TypeError):
|
|
148
|
+
return np.array([_unit_to_float(v) for v in val], dtype=float)
|
|
149
|
+
return np.atleast_1d(np.asarray(val, dtype=float))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Registry for custom grob renderers. Maps _grid_class string to a
|
|
153
|
+
# callable(grob, renderer, gp) that performs the rendering.
|
|
154
|
+
_GROB_RENDERERS: Dict[str, Callable] = {}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def register_grob_renderer(cls_name: str, fn: Callable) -> None:
|
|
158
|
+
"""Register a custom renderer for a grob class.
|
|
159
|
+
|
|
160
|
+
Parameters
|
|
161
|
+
----------
|
|
162
|
+
cls_name : str
|
|
163
|
+
The ``_grid_class`` value to handle.
|
|
164
|
+
fn : callable
|
|
165
|
+
A function ``(grob, renderer, gp) -> None`` that renders the grob.
|
|
166
|
+
"""
|
|
167
|
+
_GROB_RENDERERS[cls_name] = fn
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _draw_arrow_heads(
|
|
171
|
+
xs: np.ndarray,
|
|
172
|
+
ys: np.ndarray,
|
|
173
|
+
arrow: Any,
|
|
174
|
+
renderer: Any,
|
|
175
|
+
gp: Optional[Gpar],
|
|
176
|
+
) -> None:
|
|
177
|
+
"""Draw arrowheads at the requested end(s) of a polyline.
|
|
178
|
+
|
|
179
|
+
Parameters
|
|
180
|
+
----------
|
|
181
|
+
xs, ys : ndarray
|
|
182
|
+
The polyline's x / y coordinates in device space.
|
|
183
|
+
arrow : Arrow
|
|
184
|
+
Arrow specification (angle, length, ends, type). May be ``None`` —
|
|
185
|
+
caller should guard.
|
|
186
|
+
renderer, gp : rendering context.
|
|
187
|
+
"""
|
|
188
|
+
import math
|
|
189
|
+
|
|
190
|
+
xs = np.asarray(xs, dtype=float)
|
|
191
|
+
ys = np.asarray(ys, dtype=float)
|
|
192
|
+
n = len(xs)
|
|
193
|
+
if n < 2:
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
angle_deg = float(np.atleast_1d(arrow.angle)[0])
|
|
197
|
+
ends = int(np.atleast_1d(arrow.ends)[0])
|
|
198
|
+
atype = int(np.atleast_1d(arrow.type)[0])
|
|
199
|
+
# Arrow length is a Unit; resolve in device width (x-direction dimension).
|
|
200
|
+
length_unit = arrow.length
|
|
201
|
+
if hasattr(length_unit, "__len__"):
|
|
202
|
+
from ._units import Unit
|
|
203
|
+
if isinstance(length_unit, Unit):
|
|
204
|
+
length_unit = length_unit[0] if len(length_unit) > 0 else length_unit
|
|
205
|
+
# R: ``l = fmin2(transformWidthtoINCHES(...), transformHeighttoINCHES(...))``
|
|
206
|
+
# (src/library/grid/src/grid.c::calcArrow). Resolving the length as both
|
|
207
|
+
# width and height and taking the min keeps the arrowhead aspect-ratio
|
|
208
|
+
# stable when the length is given in non-absolute units like ``npc``.
|
|
209
|
+
try:
|
|
210
|
+
l_w = float(renderer.resolve_w(length_unit, gp=gp))
|
|
211
|
+
l_h = float(renderer.resolve_h(length_unit, gp=gp))
|
|
212
|
+
except Exception:
|
|
213
|
+
return
|
|
214
|
+
length_dev = min(l_w, l_h)
|
|
215
|
+
if not (length_dev > 0):
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
half_angle = math.radians(angle_deg)
|
|
219
|
+
cos_a = math.cos(half_angle)
|
|
220
|
+
sin_a = math.sin(half_angle)
|
|
221
|
+
|
|
222
|
+
endpoints: List[Tuple[float, float, float, float]] = []
|
|
223
|
+
# ``ends``: 1 = first, 2 = last, 3 = both. For each, we record the
|
|
224
|
+
# arrow tip (tip_x, tip_y) and the *inward* tangent direction
|
|
225
|
+
# (tan_x, tan_y) — pointing from the tip back along the shaft.
|
|
226
|
+
if ends in (1, 3):
|
|
227
|
+
tx = xs[1] - xs[0]
|
|
228
|
+
ty = ys[1] - ys[0]
|
|
229
|
+
L = math.hypot(tx, ty)
|
|
230
|
+
if L > 0:
|
|
231
|
+
endpoints.append((float(xs[0]), float(ys[0]), tx / L, ty / L))
|
|
232
|
+
if ends in (2, 3):
|
|
233
|
+
tx = xs[-2] - xs[-1]
|
|
234
|
+
ty = ys[-2] - ys[-1]
|
|
235
|
+
L = math.hypot(tx, ty)
|
|
236
|
+
if L > 0:
|
|
237
|
+
endpoints.append((float(xs[-1]), float(ys[-1]), tx / L, ty / L))
|
|
238
|
+
|
|
239
|
+
for tip_x, tip_y, tan_x, tan_y in endpoints:
|
|
240
|
+
# Wing tips: rotate the inward tangent by ±half_angle and scale by
|
|
241
|
+
# length_dev; add to the tip.
|
|
242
|
+
w1x = tip_x + length_dev * (cos_a * tan_x - sin_a * tan_y)
|
|
243
|
+
w1y = tip_y + length_dev * (sin_a * tan_x + cos_a * tan_y)
|
|
244
|
+
w2x = tip_x + length_dev * (cos_a * tan_x + sin_a * tan_y)
|
|
245
|
+
w2y = tip_y + length_dev * (-sin_a * tan_x + cos_a * tan_y)
|
|
246
|
+
|
|
247
|
+
if atype == 1:
|
|
248
|
+
# Open: two short line segments forming a V (tip -> wing1, tip -> wing2).
|
|
249
|
+
renderer.draw_segments(
|
|
250
|
+
x0=np.array([tip_x, tip_x], dtype=float),
|
|
251
|
+
y0=np.array([tip_y, tip_y], dtype=float),
|
|
252
|
+
x1=np.array([w1x, w2x], dtype=float),
|
|
253
|
+
y1=np.array([w1y, w2y], dtype=float),
|
|
254
|
+
gp=gp,
|
|
255
|
+
)
|
|
256
|
+
else:
|
|
257
|
+
# Closed: filled triangle wing1 -> tip -> wing2.
|
|
258
|
+
renderer.draw_polygon(
|
|
259
|
+
np.array([w1x, tip_x, w2x], dtype=float),
|
|
260
|
+
np.array([w1y, tip_y, w2y], dtype=float),
|
|
261
|
+
gp=gp,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _render_grob(
|
|
266
|
+
grob: Grob,
|
|
267
|
+
renderer: Any,
|
|
268
|
+
gp: Optional[Gpar] = None,
|
|
269
|
+
transform: Optional[np.ndarray] = None,
|
|
270
|
+
) -> None:
|
|
271
|
+
"""Render a single grob via the current :class:`GridRenderer` backend.
|
|
272
|
+
|
|
273
|
+
Dispatches on ``grob._grid_class`` to call the appropriate renderer
|
|
274
|
+
method.
|
|
275
|
+
|
|
276
|
+
Parameters
|
|
277
|
+
----------
|
|
278
|
+
grob : Grob
|
|
279
|
+
The graphical object to render.
|
|
280
|
+
renderer : GridRenderer
|
|
281
|
+
The rendering backend.
|
|
282
|
+
gp : Gpar or None, optional
|
|
283
|
+
Resolved graphical parameters (merged from context + grob).
|
|
284
|
+
transform : numpy.ndarray or None, optional
|
|
285
|
+
3x3 affine transform matrix; currently reserved for future use.
|
|
286
|
+
"""
|
|
287
|
+
if renderer is None:
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
# Pass grob metadata to renderer for interactive data attachment
|
|
291
|
+
metadata = getattr(grob, "metadata", None)
|
|
292
|
+
if metadata is not None and hasattr(renderer, "set_grob_metadata"):
|
|
293
|
+
renderer.set_grob_metadata(metadata)
|
|
294
|
+
|
|
295
|
+
cls = getattr(grob, "_grid_class", "grob")
|
|
296
|
+
|
|
297
|
+
# ---- rect -----------------------------------------------------------
|
|
298
|
+
if cls == "rect":
|
|
299
|
+
xs = renderer.resolve_x_array(getattr(grob, "x", [0.0]), gp=gp)
|
|
300
|
+
ys = renderer.resolve_y_array(getattr(grob, "y", [0.0]), gp=gp)
|
|
301
|
+
ws = renderer.resolve_w_array(getattr(grob, "width", [1.0]), gp=gp)
|
|
302
|
+
hs = renderer.resolve_h_array(getattr(grob, "height", [1.0]), gp=gp)
|
|
303
|
+
hj, vj = _resolve_just(grob)
|
|
304
|
+
n = max(len(xs), len(ys), len(ws), len(hs))
|
|
305
|
+
if len(xs) == 1:
|
|
306
|
+
xs = np.full(n, xs[0])
|
|
307
|
+
if len(ys) == 1:
|
|
308
|
+
ys = np.full(n, ys[0])
|
|
309
|
+
if len(ws) == 1:
|
|
310
|
+
ws = np.full(n, ws[0])
|
|
311
|
+
if len(hs) == 1:
|
|
312
|
+
hs = np.full(n, hs[0])
|
|
313
|
+
for i in range(n):
|
|
314
|
+
gp_i = _subset_gpar(gp, i) if n > 1 else gp
|
|
315
|
+
renderer.draw_rect(
|
|
316
|
+
x=float(xs[i]), y=float(ys[i]),
|
|
317
|
+
w=float(ws[i]), h=float(hs[i]),
|
|
318
|
+
hjust=hj, vjust=vj, gp=gp_i,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# ---- roundrect ------------------------------------------------------
|
|
322
|
+
elif cls == "roundrect":
|
|
323
|
+
renderer.draw_roundrect(
|
|
324
|
+
x=renderer.resolve_x(getattr(grob, "x", 0.0), gp=gp),
|
|
325
|
+
y=renderer.resolve_y(getattr(grob, "y", 0.0), gp=gp),
|
|
326
|
+
w=renderer.resolve_w(getattr(grob, "width", 1.0), gp=gp),
|
|
327
|
+
h=renderer.resolve_h(getattr(grob, "height", 1.0), gp=gp),
|
|
328
|
+
r=renderer.resolve_w(getattr(grob, "r", 0.0), gp=gp),
|
|
329
|
+
hjust=float(getattr(grob, "hjust", None) or 0.5),
|
|
330
|
+
vjust=float(getattr(grob, "vjust", None) or 0.5),
|
|
331
|
+
gp=gp,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# ---- circle ---------------------------------------------------------
|
|
335
|
+
elif cls == "circle":
|
|
336
|
+
renderer.draw_circle(
|
|
337
|
+
x=renderer.resolve_x(getattr(grob, "x", 0.5), gp=gp),
|
|
338
|
+
y=renderer.resolve_y(getattr(grob, "y", 0.5), gp=gp),
|
|
339
|
+
r=renderer.resolve_w(getattr(grob, "r", 0.5), gp=gp),
|
|
340
|
+
gp=gp,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
# ---- lines / polyline ------------------------------------------------
|
|
344
|
+
elif cls in ("lines", "polyline"):
|
|
345
|
+
x = renderer.resolve_x_array(getattr(grob, "x", [0.0, 1.0]), gp=gp)
|
|
346
|
+
y = renderer.resolve_y_array(getattr(grob, "y", [0.0, 1.0]), gp=gp)
|
|
347
|
+
id_ = getattr(grob, "id", None)
|
|
348
|
+
id_lengths = getattr(grob, "id_lengths", None)
|
|
349
|
+
# R polylineGrob supports either `id` (per-point group) or
|
|
350
|
+
# `id.lengths` (run-length encoded). If only lengths were
|
|
351
|
+
# given, expand them into a per-point id vector so the renderer
|
|
352
|
+
# correctly breaks sub-polylines.
|
|
353
|
+
if id_ is None and id_lengths is not None:
|
|
354
|
+
lengths = np.atleast_1d(np.asarray(id_lengths, dtype=int))
|
|
355
|
+
id_ = np.repeat(np.arange(1, len(lengths) + 1), lengths)
|
|
356
|
+
if id_ is not None:
|
|
357
|
+
id_ = np.atleast_1d(np.asarray(id_, dtype=int))
|
|
358
|
+
renderer.draw_polyline(x, y, id_=id_, gp=gp)
|
|
359
|
+
|
|
360
|
+
# ---- segments --------------------------------------------------------
|
|
361
|
+
elif cls == "segments":
|
|
362
|
+
x0 = renderer.resolve_x_array(getattr(grob, "x0", []), gp=gp)
|
|
363
|
+
y0 = renderer.resolve_y_array(getattr(grob, "y0", []), gp=gp)
|
|
364
|
+
x1 = renderer.resolve_x_array(getattr(grob, "x1", []), gp=gp)
|
|
365
|
+
y1 = renderer.resolve_y_array(getattr(grob, "y1", []), gp=gp)
|
|
366
|
+
renderer.draw_segments(x0=x0, y0=y0, x1=x1, y1=y1, gp=gp)
|
|
367
|
+
|
|
368
|
+
# Each segment may carry its own arrowhead (``arrow=`` parameter on
|
|
369
|
+
# segmentsGrob). Draw one per row, treating the segment's two points
|
|
370
|
+
# as the reference polyline.
|
|
371
|
+
arr = getattr(grob, "arrow", None)
|
|
372
|
+
if arr is not None:
|
|
373
|
+
for i in range(len(x0)):
|
|
374
|
+
_draw_arrow_heads(
|
|
375
|
+
np.array([float(x0[i]), float(x1[i])]),
|
|
376
|
+
np.array([float(y0[i]), float(y1[i])]),
|
|
377
|
+
arr, renderer, gp,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
# ---- xspline ---------------------------------------------------------
|
|
381
|
+
elif cls == "xspline":
|
|
382
|
+
from ._curve import _calc_xspline_points # lazy to avoid import cycle
|
|
383
|
+
|
|
384
|
+
x = renderer.resolve_x_array(getattr(grob, "x", [0.0, 1.0]), gp=gp)
|
|
385
|
+
y = renderer.resolve_y_array(getattr(grob, "y", [0.0, 1.0]), gp=gp)
|
|
386
|
+
shape_raw = getattr(grob, "shape", 0.0)
|
|
387
|
+
open_ = bool(getattr(grob, "open_", True))
|
|
388
|
+
rep_ends = bool(getattr(grob, "repEnds", True))
|
|
389
|
+
|
|
390
|
+
id_ = getattr(grob, "id", None)
|
|
391
|
+
id_lengths = getattr(grob, "id_lengths", None)
|
|
392
|
+
if id_ is None and id_lengths is not None:
|
|
393
|
+
lengths = np.atleast_1d(np.asarray(id_lengths, dtype=int))
|
|
394
|
+
id_ = np.repeat(np.arange(1, len(lengths) + 1), lengths)
|
|
395
|
+
|
|
396
|
+
if np.isscalar(shape_raw):
|
|
397
|
+
shape_arr = np.full(len(x), float(shape_raw))
|
|
398
|
+
else:
|
|
399
|
+
shape_arr = np.atleast_1d(np.asarray(shape_raw, dtype=float))
|
|
400
|
+
if len(shape_arr) < len(x):
|
|
401
|
+
shape_arr = np.resize(shape_arr, len(x))
|
|
402
|
+
|
|
403
|
+
arr = getattr(grob, "arrow", None)
|
|
404
|
+
|
|
405
|
+
if id_ is None:
|
|
406
|
+
xs, ys = _calc_xspline_points(
|
|
407
|
+
x, y, shape=shape_arr, open_=open_, repEnds=rep_ends,
|
|
408
|
+
)
|
|
409
|
+
renderer.draw_polyline(xs, ys, id_=None, gp=gp)
|
|
410
|
+
if arr is not None and len(xs) >= 2:
|
|
411
|
+
_draw_arrow_heads(xs, ys, arr, renderer, gp)
|
|
412
|
+
else:
|
|
413
|
+
id_arr = np.atleast_1d(np.asarray(id_, dtype=int))
|
|
414
|
+
all_xs: List[float] = []
|
|
415
|
+
all_ys: List[float] = []
|
|
416
|
+
all_ids: List[int] = []
|
|
417
|
+
out_id = 1
|
|
418
|
+
per_group: List[Tuple[np.ndarray, np.ndarray]] = []
|
|
419
|
+
for uid in np.unique(id_arr):
|
|
420
|
+
mask = id_arr == uid
|
|
421
|
+
xs_g, ys_g = _calc_xspline_points(
|
|
422
|
+
x[mask], y[mask],
|
|
423
|
+
shape=shape_arr[mask],
|
|
424
|
+
open_=open_,
|
|
425
|
+
repEnds=rep_ends,
|
|
426
|
+
)
|
|
427
|
+
all_xs.extend(xs_g.tolist())
|
|
428
|
+
all_ys.extend(ys_g.tolist())
|
|
429
|
+
all_ids.extend([out_id] * len(xs_g))
|
|
430
|
+
out_id += 1
|
|
431
|
+
per_group.append((xs_g, ys_g))
|
|
432
|
+
if all_xs:
|
|
433
|
+
renderer.draw_polyline(
|
|
434
|
+
np.asarray(all_xs, dtype=float),
|
|
435
|
+
np.asarray(all_ys, dtype=float),
|
|
436
|
+
id_=np.asarray(all_ids, dtype=int),
|
|
437
|
+
gp=gp,
|
|
438
|
+
)
|
|
439
|
+
if arr is not None:
|
|
440
|
+
for xs_g, ys_g in per_group:
|
|
441
|
+
if len(xs_g) >= 2:
|
|
442
|
+
_draw_arrow_heads(xs_g, ys_g, arr, renderer, gp)
|
|
443
|
+
|
|
444
|
+
# ---- polygon ---------------------------------------------------------
|
|
445
|
+
elif cls == "polygon":
|
|
446
|
+
px = renderer.resolve_x_array(getattr(grob, "x", []), gp=gp)
|
|
447
|
+
py = renderer.resolve_y_array(getattr(grob, "y", []), gp=gp)
|
|
448
|
+
pid = getattr(grob, "id", None)
|
|
449
|
+
if pid is not None:
|
|
450
|
+
# R semantics: polygonGrob(id=...) draws separate polygons
|
|
451
|
+
# per unique id value, each with its own fill/stroke.
|
|
452
|
+
pid = np.atleast_1d(np.asarray(pid))
|
|
453
|
+
unique_ids = np.unique(pid)
|
|
454
|
+
for idx, uid in enumerate(unique_ids):
|
|
455
|
+
mask = pid == uid
|
|
456
|
+
gp_i = _subset_gpar(gp, idx) if gp else gp
|
|
457
|
+
renderer.draw_polygon(px[mask], py[mask], gp=gp_i)
|
|
458
|
+
else:
|
|
459
|
+
renderer.draw_polygon(px, py, gp=gp)
|
|
460
|
+
|
|
461
|
+
# ---- text ------------------------------------------------------------
|
|
462
|
+
# Port of R grid.c:3629-3860 gridText():
|
|
463
|
+
# nx = max(length(x), length(y))
|
|
464
|
+
# for i in 0..nx-1:
|
|
465
|
+
# GEText(xx[i], yy[i], txt[i % ntxt], hjust[i%nh], vjust[i%nv], rot[i%nr])
|
|
466
|
+
elif cls == "text":
|
|
467
|
+
label_raw = getattr(grob, "label", "")
|
|
468
|
+
x_unit = getattr(grob, "x", 0.5)
|
|
469
|
+
y_unit = getattr(grob, "y", 0.5)
|
|
470
|
+
rot_raw = getattr(grob, "rot", 0.0)
|
|
471
|
+
hj, vj = _resolve_just(grob)
|
|
472
|
+
|
|
473
|
+
# Normalise label to a list
|
|
474
|
+
if isinstance(label_raw, str):
|
|
475
|
+
labels = [label_raw]
|
|
476
|
+
elif isinstance(label_raw, (list, tuple, np.ndarray)):
|
|
477
|
+
labels = [str(l) for l in label_raw]
|
|
478
|
+
else:
|
|
479
|
+
labels = [str(label_raw)]
|
|
480
|
+
|
|
481
|
+
# Resolve x/y to arrays
|
|
482
|
+
xx = renderer.resolve_x_array(x_unit, gp=gp)
|
|
483
|
+
yy = renderer.resolve_y_array(y_unit, gp=gp)
|
|
484
|
+
|
|
485
|
+
# Normalise rot to array
|
|
486
|
+
if isinstance(rot_raw, (list, tuple, np.ndarray)):
|
|
487
|
+
rots = np.atleast_1d(np.asarray(rot_raw, dtype=float))
|
|
488
|
+
else:
|
|
489
|
+
rots = np.array([float(rot_raw)])
|
|
490
|
+
|
|
491
|
+
# Normalise hjust/vjust to arrays
|
|
492
|
+
hjust_raw = getattr(grob, "hjust", None)
|
|
493
|
+
vjust_raw = getattr(grob, "vjust", None)
|
|
494
|
+
if isinstance(hj, (list, tuple, np.ndarray)):
|
|
495
|
+
hjs = np.atleast_1d(np.asarray(hj, dtype=float))
|
|
496
|
+
else:
|
|
497
|
+
hjs = np.array([float(hj)])
|
|
498
|
+
if isinstance(vj, (list, tuple, np.ndarray)):
|
|
499
|
+
vjs = np.atleast_1d(np.asarray(vj, dtype=float))
|
|
500
|
+
else:
|
|
501
|
+
vjs = np.array([float(vj)])
|
|
502
|
+
|
|
503
|
+
# R: nx = max(length(x), length(y))
|
|
504
|
+
nx = max(len(xx), len(yy))
|
|
505
|
+
ntxt = len(labels)
|
|
506
|
+
nrot = len(rots)
|
|
507
|
+
nhj = len(hjs)
|
|
508
|
+
nvj = len(vjs)
|
|
509
|
+
|
|
510
|
+
for i in range(nx):
|
|
511
|
+
gp_i = _subset_gpar(gp, i) if gp else gp
|
|
512
|
+
renderer.draw_text(
|
|
513
|
+
x=xx[i % len(xx)],
|
|
514
|
+
y=yy[i % len(yy)],
|
|
515
|
+
label=labels[i % ntxt],
|
|
516
|
+
rot=float(rots[i % nrot]),
|
|
517
|
+
hjust=float(hjs[i % nhj]),
|
|
518
|
+
vjust=float(vjs[i % nvj]),
|
|
519
|
+
gp=gp_i,
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
# ---- points ----------------------------------------------------------
|
|
523
|
+
elif cls == "points":
|
|
524
|
+
pch_raw = getattr(grob, "pch", 19)
|
|
525
|
+
# pch may be a scalar or per-point array — pass through as-is
|
|
526
|
+
if isinstance(pch_raw, (np.ndarray, list, tuple)):
|
|
527
|
+
pch_val = np.asarray(pch_raw, dtype=int)
|
|
528
|
+
elif isinstance(pch_raw, (int, float, np.integer, np.floating)):
|
|
529
|
+
pch_val = int(pch_raw)
|
|
530
|
+
else:
|
|
531
|
+
pch_val = 19
|
|
532
|
+
renderer.draw_points(
|
|
533
|
+
x=renderer.resolve_x_array(getattr(grob, "x", []), gp=gp),
|
|
534
|
+
y=renderer.resolve_y_array(getattr(grob, "y", []), gp=gp),
|
|
535
|
+
size=renderer.resolve_w(getattr(grob, "size", 1.0), gp=gp),
|
|
536
|
+
pch=pch_val,
|
|
537
|
+
gp=gp,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
# ---- pathgrob --------------------------------------------------------
|
|
541
|
+
elif cls == "pathgrob":
|
|
542
|
+
x = renderer.resolve_x_array(getattr(grob, "x", []), gp=gp)
|
|
543
|
+
y = renderer.resolve_y_array(getattr(grob, "y", []), gp=gp)
|
|
544
|
+
path_id = getattr(grob, "pathId", None)
|
|
545
|
+
if path_id is None:
|
|
546
|
+
path_id = np.ones(len(x), dtype=int)
|
|
547
|
+
else:
|
|
548
|
+
path_id = np.atleast_1d(np.asarray(path_id, dtype=int))
|
|
549
|
+
renderer.draw_path(
|
|
550
|
+
x=x, y=y, path_id=path_id,
|
|
551
|
+
rule=getattr(grob, "rule", "winding"),
|
|
552
|
+
gp=gp,
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
# ---- rastergrob ------------------------------------------------------
|
|
556
|
+
elif cls == "rastergrob":
|
|
557
|
+
image = getattr(grob, "raster", None)
|
|
558
|
+
if image is None:
|
|
559
|
+
image = getattr(grob, "image", None)
|
|
560
|
+
if image is not None:
|
|
561
|
+
# Apply justification (same as rect_grob)
|
|
562
|
+
hj, vj = _resolve_just(grob)
|
|
563
|
+
raw_x = renderer.resolve_x(getattr(grob, "x", 0.0), gp=gp)
|
|
564
|
+
raw_y = renderer.resolve_y(getattr(grob, "y", 0.0), gp=gp)
|
|
565
|
+
raw_w = renderer.resolve_w(getattr(grob, "width", 1.0), gp=gp)
|
|
566
|
+
raw_h = renderer.resolve_h(getattr(grob, "height", 1.0), gp=gp)
|
|
567
|
+
# Compute bottom-left corner from anchor + justification
|
|
568
|
+
x0 = raw_x - raw_w * hj
|
|
569
|
+
y0 = raw_y - raw_h * vj
|
|
570
|
+
renderer.draw_raster(
|
|
571
|
+
image=image,
|
|
572
|
+
x=x0,
|
|
573
|
+
y=y0,
|
|
574
|
+
w=raw_w,
|
|
575
|
+
h=raw_h,
|
|
576
|
+
interpolate=getattr(grob, "interpolate", True),
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
# ---- GridStroke / GridFill / GridFillStroke (R 4.2+, path.R) ----------
|
|
580
|
+
elif cls == "GridStroke":
|
|
581
|
+
path_grob = getattr(grob, "path", None)
|
|
582
|
+
if path_grob is not None and hasattr(renderer, "begin_path_collect"):
|
|
583
|
+
renderer.save_state()
|
|
584
|
+
renderer.begin_path_collect()
|
|
585
|
+
_render_grob(path_grob, renderer, gp=gp)
|
|
586
|
+
renderer.end_path_stroke(gp)
|
|
587
|
+
renderer.restore_state()
|
|
588
|
+
|
|
589
|
+
elif cls == "GridFill":
|
|
590
|
+
path_grob = getattr(grob, "path", None)
|
|
591
|
+
rule = getattr(grob, "rule", "winding")
|
|
592
|
+
if path_grob is not None and hasattr(renderer, "begin_path_collect"):
|
|
593
|
+
renderer.save_state()
|
|
594
|
+
renderer.begin_path_collect(rule=rule)
|
|
595
|
+
_render_grob(path_grob, renderer, gp=gp)
|
|
596
|
+
renderer.end_path_fill(gp)
|
|
597
|
+
renderer.restore_state()
|
|
598
|
+
|
|
599
|
+
elif cls == "GridFillStroke":
|
|
600
|
+
path_grob = getattr(grob, "path", None)
|
|
601
|
+
rule = getattr(grob, "rule", "winding")
|
|
602
|
+
if path_grob is not None and hasattr(renderer, "begin_path_collect"):
|
|
603
|
+
renderer.save_state()
|
|
604
|
+
renderer.begin_path_collect(rule=rule)
|
|
605
|
+
_render_grob(path_grob, renderer, gp=gp)
|
|
606
|
+
renderer.end_path_fill_stroke(gp)
|
|
607
|
+
renderer.restore_state()
|
|
608
|
+
|
|
609
|
+
# ---- null / gTree / base grob – no-op --------------------------------
|
|
610
|
+
elif cls in ("null", "grob", "gTree", "frame", "cellGrob",
|
|
611
|
+
"xaxis", "yaxis", "delayedgrob", "recordedGrob"):
|
|
612
|
+
pass
|
|
613
|
+
|
|
614
|
+
# ---- move.to / line.to -----------------------------------------------
|
|
615
|
+
elif cls == "move.to":
|
|
616
|
+
renderer.move_to(
|
|
617
|
+
renderer.resolve_x(getattr(grob, "x", 0.0), gp=gp),
|
|
618
|
+
renderer.resolve_y(getattr(grob, "y", 0.0), gp=gp),
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
elif cls == "line.to":
|
|
622
|
+
renderer.line_to(
|
|
623
|
+
renderer.resolve_x(getattr(grob, "x", 0.0), gp=gp),
|
|
624
|
+
renderer.resolve_y(getattr(grob, "y", 0.0), gp=gp),
|
|
625
|
+
gp=gp,
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
elif cls in _GROB_RENDERERS:
|
|
629
|
+
_GROB_RENDERERS[cls](grob, renderer, gp)
|
|
630
|
+
|
|
631
|
+
else:
|
|
632
|
+
# Unknown ``_grid_class`` → silent no-op. Any grob without a
|
|
633
|
+
# dedicated draw routine or renderer registration simply draws
|
|
634
|
+
# nothing.
|
|
635
|
+
pass
|
|
636
|
+
|
|
637
|
+
# Clear grob metadata after rendering
|
|
638
|
+
if metadata is not None and hasattr(renderer, "clear_grob_metadata"):
|
|
639
|
+
renderer.clear_grob_metadata()
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
# ---------------------------------------------------------------------------
|
|
643
|
+
# Viewport / gpar push/pop helpers
|
|
644
|
+
# ---------------------------------------------------------------------------
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _push_grob_vp(vp: Any) -> None:
|
|
648
|
+
"""Push a grob's viewport (or navigate down for a VpPath).
|
|
649
|
+
|
|
650
|
+
``push_viewport`` / ``down_viewport`` already synchronise the
|
|
651
|
+
renderer's coordinate transform, so no extra renderer call is needed.
|
|
652
|
+
|
|
653
|
+
Parameters
|
|
654
|
+
----------
|
|
655
|
+
vp : Viewport or VpPath
|
|
656
|
+
The viewport to push or navigate to.
|
|
657
|
+
"""
|
|
658
|
+
from ._viewport import Viewport, push_viewport, down_viewport
|
|
659
|
+
from ._path import VpPath
|
|
660
|
+
|
|
661
|
+
if isinstance(vp, VpPath):
|
|
662
|
+
down_viewport(vp, strict=True, recording=False)
|
|
663
|
+
else:
|
|
664
|
+
push_viewport(vp, recording=False)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _pop_grob_vp(vp: Any) -> None:
|
|
668
|
+
"""Pop/navigate up from a grob's viewport.
|
|
669
|
+
|
|
670
|
+
``up_viewport`` already synchronises the renderer's coordinate
|
|
671
|
+
transform, so no extra renderer call is needed.
|
|
672
|
+
|
|
673
|
+
Parameters
|
|
674
|
+
----------
|
|
675
|
+
vp : Viewport or VpPath
|
|
676
|
+
The viewport that was previously pushed.
|
|
677
|
+
"""
|
|
678
|
+
from ._viewport import up_viewport
|
|
679
|
+
|
|
680
|
+
d = _vp_depth(vp)
|
|
681
|
+
up_viewport(d, recording=False)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _vp_depth(vp: Any) -> int:
|
|
685
|
+
"""Return the depth of a viewport (number of levels it adds).
|
|
686
|
+
|
|
687
|
+
Parameters
|
|
688
|
+
----------
|
|
689
|
+
vp : Any
|
|
690
|
+
A viewport, VpPath, VpStack, VpList, or VpTree.
|
|
691
|
+
|
|
692
|
+
Returns
|
|
693
|
+
-------
|
|
694
|
+
int
|
|
695
|
+
The depth.
|
|
696
|
+
"""
|
|
697
|
+
from ._path import VpPath
|
|
698
|
+
|
|
699
|
+
if isinstance(vp, VpPath):
|
|
700
|
+
# VpPath stores the number of path components
|
|
701
|
+
return getattr(vp, "n", 1)
|
|
702
|
+
if hasattr(vp, "depth"):
|
|
703
|
+
return vp.depth()
|
|
704
|
+
# Default single viewport depth
|
|
705
|
+
return 1
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _push_vp_gp(grob: Grob) -> None:
|
|
709
|
+
"""Push the grob's viewport and apply its gpar.
|
|
710
|
+
|
|
711
|
+
Parameters
|
|
712
|
+
----------
|
|
713
|
+
grob : Grob
|
|
714
|
+
The grob whose ``vp`` and ``gp`` should be activated.
|
|
715
|
+
"""
|
|
716
|
+
state = get_state()
|
|
717
|
+
if grob.vp is not None:
|
|
718
|
+
_push_grob_vp(grob.vp)
|
|
719
|
+
if grob.gp is not None:
|
|
720
|
+
state.set_gpar(grob.gp)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
# ---------------------------------------------------------------------------
|
|
724
|
+
# Draw-grob dispatcher (mirrors R's drawGrob / drawGTree / drawGList)
|
|
725
|
+
# ---------------------------------------------------------------------------
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _draw_grob(x: Grob) -> None:
|
|
729
|
+
"""Internal: draw a plain Grob (not a GTree).
|
|
730
|
+
|
|
731
|
+
Mirrors R's ``drawGrob``: disables the display list, saves gpar,
|
|
732
|
+
calls preDraw (makeContext + pushvpgp + preDrawDetails),
|
|
733
|
+
makeContent, drawDetails, postDraw, then restores state.
|
|
734
|
+
|
|
735
|
+
Parameters
|
|
736
|
+
----------
|
|
737
|
+
x : Grob
|
|
738
|
+
The grob to draw.
|
|
739
|
+
"""
|
|
740
|
+
state = get_state()
|
|
741
|
+
|
|
742
|
+
# Temporarily disable DL so nested drawing calls are not recorded
|
|
743
|
+
# (mirrors R's grid.Call(C_setDLon, FALSE))
|
|
744
|
+
saved_dl_on = state._dl_on
|
|
745
|
+
state.set_display_list_on(False)
|
|
746
|
+
|
|
747
|
+
# Save current gpar
|
|
748
|
+
saved_gpar = copy.copy(state.get_gpar())
|
|
749
|
+
|
|
750
|
+
try:
|
|
751
|
+
# preDraw: makeContext -> push vp/gp -> preDrawDetails
|
|
752
|
+
x = x.make_context()
|
|
753
|
+
_push_vp_gp(x)
|
|
754
|
+
x.pre_draw_details()
|
|
755
|
+
|
|
756
|
+
# makeContent -> pattern resolution -> drawDetails
|
|
757
|
+
x = x.make_content()
|
|
758
|
+
|
|
759
|
+
# Port of R grob.R:1843 recordGrobForPatternResolution(x)
|
|
760
|
+
from ._patterns import record_grob_for_pattern_resolution
|
|
761
|
+
record_grob_for_pattern_resolution(x)
|
|
762
|
+
|
|
763
|
+
x.draw_details(recording=False)
|
|
764
|
+
|
|
765
|
+
# Render via backend
|
|
766
|
+
renderer = state.get_renderer()
|
|
767
|
+
if renderer is not None:
|
|
768
|
+
merged_gp = _merge_gpar(state.get_gpar(), x.gp)
|
|
769
|
+
_render_grob(x, renderer, gp=merged_gp)
|
|
770
|
+
|
|
771
|
+
# postDraw: postDrawDetails -> pop vp
|
|
772
|
+
x.post_draw_details()
|
|
773
|
+
if x.vp is not None:
|
|
774
|
+
_pop_grob_vp(x.vp)
|
|
775
|
+
finally:
|
|
776
|
+
# Restore gpar and DL state
|
|
777
|
+
state.replace_gpar(saved_gpar)
|
|
778
|
+
state.set_display_list_on(saved_dl_on)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _draw_gtree(x: GTree) -> None:
|
|
782
|
+
"""Internal: draw a GTree.
|
|
783
|
+
|
|
784
|
+
Mirrors R's ``drawGTree``: disables the display list, saves gpar +
|
|
785
|
+
current grob context, calls preDraw (makeContext + setCurrentGrob +
|
|
786
|
+
pushvpgp + children vp + preDrawDetails), makeContent, drawDetails,
|
|
787
|
+
draws children in order, then postDraw, then restores state.
|
|
788
|
+
|
|
789
|
+
Parameters
|
|
790
|
+
----------
|
|
791
|
+
x : GTree
|
|
792
|
+
The gTree to draw.
|
|
793
|
+
"""
|
|
794
|
+
state = get_state()
|
|
795
|
+
|
|
796
|
+
# Temporarily disable DL so nested drawing calls are not recorded
|
|
797
|
+
saved_dl_on = state._dl_on
|
|
798
|
+
state.set_display_list_on(False)
|
|
799
|
+
|
|
800
|
+
# Save current grob and gpar (R: C_getCurrentGrob + C_getGPar)
|
|
801
|
+
saved_gpar = copy.copy(state.get_gpar())
|
|
802
|
+
saved_current_grob = getattr(state, "_current_grob", None)
|
|
803
|
+
|
|
804
|
+
try:
|
|
805
|
+
# preDraw.gTree: makeContext -> setCurrentGrob -> push vp/gp
|
|
806
|
+
x = x.make_context()
|
|
807
|
+
|
|
808
|
+
# Set this gTree as current grob for gPath-based unit evaluation
|
|
809
|
+
# (mirrors R's grid.Call.graphics(C_setCurrentGrob, x))
|
|
810
|
+
state._current_grob = x
|
|
811
|
+
|
|
812
|
+
_push_vp_gp(x)
|
|
813
|
+
|
|
814
|
+
# Push children viewport if present, then navigate back up
|
|
815
|
+
children_vp = getattr(x, "childrenvp", None) or getattr(x, "children_vp", None)
|
|
816
|
+
if children_vp is not None:
|
|
817
|
+
from ._viewport import push_viewport, up_viewport
|
|
818
|
+
temp_gp = copy.copy(state.get_gpar())
|
|
819
|
+
push_viewport(children_vp, recording=False)
|
|
820
|
+
up_viewport(_vp_depth(children_vp), recording=False)
|
|
821
|
+
state.set_gpar(temp_gp)
|
|
822
|
+
|
|
823
|
+
x.pre_draw_details()
|
|
824
|
+
|
|
825
|
+
# makeContent -> pattern resolution -> drawDetails
|
|
826
|
+
x = x.make_content()
|
|
827
|
+
|
|
828
|
+
# Port of R grob.R:1913 recordGTreeForPatternResolution(x)
|
|
829
|
+
from ._patterns import record_gtree_for_pattern_resolution
|
|
830
|
+
record_gtree_for_pattern_resolution(x)
|
|
831
|
+
|
|
832
|
+
x.draw_details(recording=False)
|
|
833
|
+
|
|
834
|
+
# Render the gTree itself (in case it has direct content)
|
|
835
|
+
renderer = state.get_renderer()
|
|
836
|
+
if renderer is not None:
|
|
837
|
+
merged_gp = _merge_gpar(state.get_gpar(), x.gp)
|
|
838
|
+
_render_grob(x, renderer, gp=merged_gp)
|
|
839
|
+
|
|
840
|
+
# Draw children in order
|
|
841
|
+
for child_name in x._children_order:
|
|
842
|
+
child = x._children.get(child_name)
|
|
843
|
+
if child is not None:
|
|
844
|
+
grid_draw(child, recording=False)
|
|
845
|
+
|
|
846
|
+
# postDraw: postDrawDetails -> pop vp
|
|
847
|
+
x.post_draw_details()
|
|
848
|
+
if x.vp is not None:
|
|
849
|
+
_pop_grob_vp(x.vp)
|
|
850
|
+
finally:
|
|
851
|
+
# Restore gpar, current grob, and DL state
|
|
852
|
+
state.replace_gpar(saved_gpar)
|
|
853
|
+
state._current_grob = saved_current_grob
|
|
854
|
+
state.set_display_list_on(saved_dl_on)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _draw_glist(x: GList) -> None:
|
|
858
|
+
"""Internal: draw every grob in a GList.
|
|
859
|
+
|
|
860
|
+
Each child is drawn individually via :func:`grid_draw`.
|
|
861
|
+
|
|
862
|
+
Parameters
|
|
863
|
+
----------
|
|
864
|
+
x : GList
|
|
865
|
+
The list of grobs to draw.
|
|
866
|
+
"""
|
|
867
|
+
for grob in x:
|
|
868
|
+
grid_draw(grob, recording=True)
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _merge_gpar(context_gp: Optional[Gpar], grob_gp: Optional[Gpar]) -> Gpar:
|
|
872
|
+
"""Merge context graphical parameters with grob-level overrides.
|
|
873
|
+
|
|
874
|
+
Parameters
|
|
875
|
+
----------
|
|
876
|
+
context_gp : Gpar or None
|
|
877
|
+
The inherited graphical parameters from the viewport stack.
|
|
878
|
+
grob_gp : Gpar or None
|
|
879
|
+
The grob's own graphical parameters.
|
|
880
|
+
|
|
881
|
+
Returns
|
|
882
|
+
-------
|
|
883
|
+
Gpar
|
|
884
|
+
A new Gpar with grob settings taking precedence over context.
|
|
885
|
+
"""
|
|
886
|
+
if context_gp is None and grob_gp is None:
|
|
887
|
+
return Gpar()
|
|
888
|
+
if context_gp is None:
|
|
889
|
+
return grob_gp # type: ignore[return-value]
|
|
890
|
+
if grob_gp is None:
|
|
891
|
+
return context_gp
|
|
892
|
+
|
|
893
|
+
# Build merged copy: start from context, override with grob
|
|
894
|
+
merged = copy.copy(context_gp)
|
|
895
|
+
for name in ("col", "fill", "alpha", "lty", "lwd", "lex",
|
|
896
|
+
"lineend", "linejoin", "linemitre",
|
|
897
|
+
"fontsize", "cex", "fontfamily", "fontface",
|
|
898
|
+
"lineheight", "font"):
|
|
899
|
+
val = grob_gp.get(name, None)
|
|
900
|
+
if val is not None:
|
|
901
|
+
merged.set(name, val)
|
|
902
|
+
return merged
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
# ---------------------------------------------------------------------------
|
|
906
|
+
# Public API
|
|
907
|
+
# ---------------------------------------------------------------------------
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def grid_draw(
|
|
911
|
+
x: Any,
|
|
912
|
+
recording: bool = True,
|
|
913
|
+
) -> None:
|
|
914
|
+
"""Draw a grob (or gList, gTree, viewport, vpPath).
|
|
915
|
+
|
|
916
|
+
This is the main entry point for rendering grid objects. It provides
|
|
917
|
+
S3-style dispatch analogous to R's ``grid.draw``:
|
|
918
|
+
|
|
919
|
+
* **Grob**: pushes ``vp`` if present, applies ``gp``, calls
|
|
920
|
+
``pre_draw_details`` / ``draw_details`` / ``post_draw_details``,
|
|
921
|
+
then pops ``vp``.
|
|
922
|
+
* **GTree**: runs ``make_context`` / ``make_content``, then draws
|
|
923
|
+
children in order.
|
|
924
|
+
* **GList**: draws each grob in sequence.
|
|
925
|
+
* **Viewport**: pushes it.
|
|
926
|
+
* **VpPath**: navigates to it.
|
|
927
|
+
|
|
928
|
+
Parameters
|
|
929
|
+
----------
|
|
930
|
+
x : Grob, GTree, GList, Viewport, VpPath, or None
|
|
931
|
+
The object to draw. ``None`` is silently ignored.
|
|
932
|
+
recording : bool, optional
|
|
933
|
+
Whether to record this operation on the display list
|
|
934
|
+
(default ``True``).
|
|
935
|
+
|
|
936
|
+
Notes
|
|
937
|
+
-----
|
|
938
|
+
Mirrors R's ``grid.draw()`` with S3 dispatch on the class of *x*.
|
|
939
|
+
"""
|
|
940
|
+
if x is None:
|
|
941
|
+
return
|
|
942
|
+
|
|
943
|
+
state = get_state()
|
|
944
|
+
|
|
945
|
+
# Late imports to avoid circular dependencies
|
|
946
|
+
from ._path import VpPath
|
|
947
|
+
|
|
948
|
+
# -- Viewport dispatch ---------------------------------------------------
|
|
949
|
+
# Import Viewport lazily
|
|
950
|
+
try:
|
|
951
|
+
from ._viewport import Viewport, push_viewport, down_viewport
|
|
952
|
+
except ImportError:
|
|
953
|
+
Viewport = type(None) # type: ignore[misc,assignment]
|
|
954
|
+
push_viewport = None # type: ignore[assignment]
|
|
955
|
+
down_viewport = None # type: ignore[assignment]
|
|
956
|
+
|
|
957
|
+
if isinstance(x, VpPath):
|
|
958
|
+
if down_viewport is not None:
|
|
959
|
+
down_viewport(x, strict=False, recording=False)
|
|
960
|
+
if recording:
|
|
961
|
+
state.record(x)
|
|
962
|
+
return
|
|
963
|
+
|
|
964
|
+
if Viewport is not None and isinstance(x, Viewport):
|
|
965
|
+
if push_viewport is not None:
|
|
966
|
+
push_viewport(x, recording=False)
|
|
967
|
+
if recording:
|
|
968
|
+
state.record(x)
|
|
969
|
+
return
|
|
970
|
+
|
|
971
|
+
# -- GList dispatch (before GTree/Grob since GTree is-a Grob) -----------
|
|
972
|
+
if isinstance(x, GList):
|
|
973
|
+
_draw_glist(x)
|
|
974
|
+
return
|
|
975
|
+
|
|
976
|
+
# -- GTree dispatch (must be checked before Grob) -----------------------
|
|
977
|
+
if isinstance(x, GTree):
|
|
978
|
+
_draw_gtree(x)
|
|
979
|
+
if recording:
|
|
980
|
+
state.record(DLDrawGrob(grob=x))
|
|
981
|
+
return
|
|
982
|
+
|
|
983
|
+
# -- Grob dispatch ------------------------------------------------------
|
|
984
|
+
if isinstance(x, Grob):
|
|
985
|
+
_draw_grob(x)
|
|
986
|
+
if recording:
|
|
987
|
+
state.record(DLDrawGrob(grob=x))
|
|
988
|
+
return
|
|
989
|
+
|
|
990
|
+
# -- Numeric "pop" / "up" dispatches (from R display list replay) -------
|
|
991
|
+
if isinstance(x, (int, float)):
|
|
992
|
+
# In R, a numeric on the display list encodes a pop/up count.
|
|
993
|
+
# We silently ignore it here; replay is handled by grid_refresh.
|
|
994
|
+
return
|
|
995
|
+
|
|
996
|
+
warnings.warn(
|
|
997
|
+
f"grid_draw: don't know how to draw object of type {type(x).__name__}",
|
|
998
|
+
stacklevel=2,
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def grid_newpage(
|
|
1003
|
+
recording: bool = True,
|
|
1004
|
+
clear_dl: bool = True,
|
|
1005
|
+
width: float = 7.0,
|
|
1006
|
+
height: float = 5.0,
|
|
1007
|
+
dpi: float = 150.0,
|
|
1008
|
+
bg: Any = "white",
|
|
1009
|
+
zoom: float = 1.0,
|
|
1010
|
+
) -> None:
|
|
1011
|
+
"""Clear the surface and start a fresh page.
|
|
1012
|
+
|
|
1013
|
+
This is equivalent to R's ``grid.newpage()``. If no
|
|
1014
|
+
renderer currently exists, a default :class:`CairoRenderer` is created.
|
|
1015
|
+
The viewport stack is reset to the root viewport.
|
|
1016
|
+
|
|
1017
|
+
Parameters
|
|
1018
|
+
----------
|
|
1019
|
+
recording : bool, optional
|
|
1020
|
+
If ``True`` (default) the display list is initialised for recording
|
|
1021
|
+
new operations.
|
|
1022
|
+
clear_dl : bool, optional
|
|
1023
|
+
If ``True`` (default) the existing display list is cleared.
|
|
1024
|
+
width : float, optional
|
|
1025
|
+
Device width in inches (default 7.0).
|
|
1026
|
+
height : float, optional
|
|
1027
|
+
Device height in inches (default 5.0).
|
|
1028
|
+
dpi : float, optional
|
|
1029
|
+
Resolution in dots per inch (default 150).
|
|
1030
|
+
bg : str or tuple, optional
|
|
1031
|
+
Background colour (default ``"white"``).
|
|
1032
|
+
zoom : float, optional
|
|
1033
|
+
GSS_SCALE zoom factor for physical units (default 1.0).
|
|
1034
|
+
Matches R's ``grid.newpage(zoom=)`` (R >= 4.2).
|
|
1035
|
+
Physical units (inches, cm, mm, points, etc.) are scaled by
|
|
1036
|
+
this factor after conversion (R unit.c:804-814).
|
|
1037
|
+
"""
|
|
1038
|
+
from .renderer import CairoRenderer
|
|
1039
|
+
|
|
1040
|
+
state = get_state()
|
|
1041
|
+
|
|
1042
|
+
# Reset all state (viewport tree, gpar stack, display list)
|
|
1043
|
+
state.reset()
|
|
1044
|
+
# Set GSS_SCALE zoom factor (R unit.c:804-814, grid state slot 15)
|
|
1045
|
+
state._scale = float(zoom)
|
|
1046
|
+
|
|
1047
|
+
# Obtain or create a renderer
|
|
1048
|
+
renderer = state.get_renderer()
|
|
1049
|
+
if renderer is None:
|
|
1050
|
+
renderer = CairoRenderer(
|
|
1051
|
+
width=width, height=height, dpi=dpi, bg=bg,
|
|
1052
|
+
)
|
|
1053
|
+
state.init_device(renderer)
|
|
1054
|
+
else:
|
|
1055
|
+
renderer.new_page(bg=bg)
|
|
1056
|
+
|
|
1057
|
+
if clear_dl:
|
|
1058
|
+
dl = state.get_display_list()
|
|
1059
|
+
dl.clear()
|
|
1060
|
+
|
|
1061
|
+
if recording:
|
|
1062
|
+
state.set_display_list_on(True)
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def grid_refresh() -> None:
|
|
1066
|
+
"""Replay the display list, redrawing the current scene.
|
|
1067
|
+
|
|
1068
|
+
Equivalent to R's ``grid.refresh()``. This calls ``grid_newpage``
|
|
1069
|
+
with ``recording=False`` and then redraws every item on the display
|
|
1070
|
+
list.
|
|
1071
|
+
"""
|
|
1072
|
+
state = get_state()
|
|
1073
|
+
dl = list(state.get_display_list()) # snapshot
|
|
1074
|
+
|
|
1075
|
+
grid_newpage(recording=False, clear_dl=False)
|
|
1076
|
+
|
|
1077
|
+
for item in dl:
|
|
1078
|
+
if hasattr(item, "grob") and item.grob is not None:
|
|
1079
|
+
grid_draw(item.grob, recording=False)
|
|
1080
|
+
elif hasattr(item, "replay"):
|
|
1081
|
+
item.replay(state)
|
|
1082
|
+
else:
|
|
1083
|
+
grid_draw(item, recording=False)
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def grid_record(
|
|
1087
|
+
expr: Callable[..., None],
|
|
1088
|
+
list_: Optional[Dict[str, Any]] = None,
|
|
1089
|
+
name: Optional[str] = None,
|
|
1090
|
+
) -> None:
|
|
1091
|
+
"""Record an expression as a grob and draw it.
|
|
1092
|
+
|
|
1093
|
+
Equivalent to R's ``grid.record()``. The *expr* callable is wrapped in
|
|
1094
|
+
a ``Grob`` with class ``"recordedGrob"`` and drawn immediately.
|
|
1095
|
+
|
|
1096
|
+
Parameters
|
|
1097
|
+
----------
|
|
1098
|
+
expr : callable
|
|
1099
|
+
A callable that performs drawing operations when called.
|
|
1100
|
+
list_ : dict or None, optional
|
|
1101
|
+
Additional variables to pass as the evaluation environment.
|
|
1102
|
+
name : str or None, optional
|
|
1103
|
+
Name for the wrapper grob.
|
|
1104
|
+
"""
|
|
1105
|
+
grob = record_grob(expr, list_=list_, name=name)
|
|
1106
|
+
grid_draw(grob)
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def record_grob(
|
|
1110
|
+
expr: Callable[..., None],
|
|
1111
|
+
list_: Optional[Dict[str, Any]] = None,
|
|
1112
|
+
name: Optional[str] = None,
|
|
1113
|
+
) -> Grob:
|
|
1114
|
+
"""Create a recorded-expression grob without drawing it.
|
|
1115
|
+
|
|
1116
|
+
Equivalent to R's ``recordGrob()``. Returns a :class:`Grob` whose
|
|
1117
|
+
``draw_details`` evaluates *expr*.
|
|
1118
|
+
|
|
1119
|
+
Parameters
|
|
1120
|
+
----------
|
|
1121
|
+
expr : callable
|
|
1122
|
+
A callable that performs drawing operations.
|
|
1123
|
+
list_ : dict or None, optional
|
|
1124
|
+
Environment mapping made available to *expr*.
|
|
1125
|
+
name : str or None, optional
|
|
1126
|
+
Name for the grob.
|
|
1127
|
+
|
|
1128
|
+
Returns
|
|
1129
|
+
-------
|
|
1130
|
+
Grob
|
|
1131
|
+
A grob with ``_grid_class="recordedGrob"`` that evaluates *expr*
|
|
1132
|
+
in its ``draw_details`` hook.
|
|
1133
|
+
"""
|
|
1134
|
+
|
|
1135
|
+
class _RecordedGrob(Grob):
|
|
1136
|
+
"""A grob that evaluates a stored callable when drawn."""
|
|
1137
|
+
|
|
1138
|
+
def __init__(
|
|
1139
|
+
self,
|
|
1140
|
+
expr_: Callable[..., None],
|
|
1141
|
+
env: Optional[Dict[str, Any]],
|
|
1142
|
+
name_: Optional[str],
|
|
1143
|
+
) -> None:
|
|
1144
|
+
self._expr = expr_
|
|
1145
|
+
self._env = env or {}
|
|
1146
|
+
super().__init__(name=name_, _grid_class="recordedGrob")
|
|
1147
|
+
|
|
1148
|
+
def draw_details(self, recording: bool = True) -> None:
|
|
1149
|
+
self._expr(**self._env)
|
|
1150
|
+
|
|
1151
|
+
return _RecordedGrob(expr_=expr, env=list_, name_=name)
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def grid_delay(
|
|
1155
|
+
expr: Callable[..., Any],
|
|
1156
|
+
list_: Optional[Dict[str, Any]] = None,
|
|
1157
|
+
name: Optional[str] = None,
|
|
1158
|
+
) -> None:
|
|
1159
|
+
"""Create a delayed-evaluation grob and draw it.
|
|
1160
|
+
|
|
1161
|
+
Equivalent to R's ``grid.delay()``. The *expr* callable must return
|
|
1162
|
+
a :class:`Grob` or :class:`GList`; evaluation is deferred to
|
|
1163
|
+
``make_content`` time.
|
|
1164
|
+
|
|
1165
|
+
Parameters
|
|
1166
|
+
----------
|
|
1167
|
+
expr : callable
|
|
1168
|
+
A callable returning a :class:`Grob` or :class:`GList`.
|
|
1169
|
+
list_ : dict or None, optional
|
|
1170
|
+
Environment mapping available to *expr*.
|
|
1171
|
+
name : str or None, optional
|
|
1172
|
+
Name for the wrapper gTree.
|
|
1173
|
+
"""
|
|
1174
|
+
grob = delay_grob(expr, list_=list_, name=name)
|
|
1175
|
+
grid_draw(grob)
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def delay_grob(
|
|
1179
|
+
expr: Callable[..., Any],
|
|
1180
|
+
list_: Optional[Dict[str, Any]] = None,
|
|
1181
|
+
name: Optional[str] = None,
|
|
1182
|
+
) -> GTree:
|
|
1183
|
+
"""Create a delayed-evaluation gTree without drawing it.
|
|
1184
|
+
|
|
1185
|
+
Equivalent to R's ``delayGrob()``. The returned :class:`GTree`
|
|
1186
|
+
evaluates *expr* in its ``make_content`` hook, which must produce a
|
|
1187
|
+
:class:`Grob` or :class:`GList`.
|
|
1188
|
+
|
|
1189
|
+
Parameters
|
|
1190
|
+
----------
|
|
1191
|
+
expr : callable
|
|
1192
|
+
A callable returning a :class:`Grob` or :class:`GList`.
|
|
1193
|
+
list_ : dict or None, optional
|
|
1194
|
+
Environment mapping available to *expr*.
|
|
1195
|
+
name : str or None, optional
|
|
1196
|
+
Name for the gTree.
|
|
1197
|
+
|
|
1198
|
+
Returns
|
|
1199
|
+
-------
|
|
1200
|
+
GTree
|
|
1201
|
+
A gTree with ``_grid_class="delayedgrob"`` whose ``make_content``
|
|
1202
|
+
evaluates *expr*.
|
|
1203
|
+
"""
|
|
1204
|
+
|
|
1205
|
+
class _DelayedGrob(GTree):
|
|
1206
|
+
"""A gTree that lazily evaluates its content."""
|
|
1207
|
+
|
|
1208
|
+
def __init__(
|
|
1209
|
+
self,
|
|
1210
|
+
expr_: Callable[..., Any],
|
|
1211
|
+
env: Optional[Dict[str, Any]],
|
|
1212
|
+
name_: Optional[str],
|
|
1213
|
+
) -> None:
|
|
1214
|
+
self._expr = expr_
|
|
1215
|
+
self._env = env or {}
|
|
1216
|
+
super().__init__(name=name_, _grid_class="delayedgrob")
|
|
1217
|
+
|
|
1218
|
+
def make_content(self) -> "GTree":
|
|
1219
|
+
result = self._expr(**self._env)
|
|
1220
|
+
if isinstance(result, Grob):
|
|
1221
|
+
children = GList(result)
|
|
1222
|
+
elif isinstance(result, GList):
|
|
1223
|
+
children = result
|
|
1224
|
+
else:
|
|
1225
|
+
raise TypeError("'expr' must return a Grob or GList")
|
|
1226
|
+
self.set_children(children)
|
|
1227
|
+
return self
|
|
1228
|
+
|
|
1229
|
+
return _DelayedGrob(expr_=expr, env=list_, name_=name)
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def grid_dl_apply(
|
|
1233
|
+
fn: Callable[[Any], Any],
|
|
1234
|
+
) -> None:
|
|
1235
|
+
"""Apply a function to each display-list item, replacing in place.
|
|
1236
|
+
|
|
1237
|
+
Equivalent to R's ``grid.DLapply()``. The function *fn* is called on
|
|
1238
|
+
every display-list entry. The return value replaces the original entry.
|
|
1239
|
+
If *fn* returns ``None`` the entry is kept as ``None``; otherwise the
|
|
1240
|
+
return value must be the same type as the original entry.
|
|
1241
|
+
|
|
1242
|
+
Parameters
|
|
1243
|
+
----------
|
|
1244
|
+
fn : callable
|
|
1245
|
+
A function ``(item) -> new_item``. *new_item* must be ``None`` or
|
|
1246
|
+
of the same class as *item*.
|
|
1247
|
+
|
|
1248
|
+
Raises
|
|
1249
|
+
------
|
|
1250
|
+
TypeError
|
|
1251
|
+
If *fn* returns a value whose type does not match the original entry.
|
|
1252
|
+
|
|
1253
|
+
Notes
|
|
1254
|
+
-----
|
|
1255
|
+
This is "blood-curdlingly dangerous" for the display-list state (to
|
|
1256
|
+
quote the R source). Two safety measures are taken:
|
|
1257
|
+
|
|
1258
|
+
1. All new elements are generated first before any assignment, so an
|
|
1259
|
+
error during generation does not trash the display list.
|
|
1260
|
+
2. Each new element is type-checked against the original.
|
|
1261
|
+
"""
|
|
1262
|
+
state = get_state()
|
|
1263
|
+
dl = state.get_display_list()
|
|
1264
|
+
|
|
1265
|
+
# Phase 1: generate replacements
|
|
1266
|
+
new_items: List[Any] = []
|
|
1267
|
+
for item in dl:
|
|
1268
|
+
new_item = fn(item)
|
|
1269
|
+
if new_item is not None and type(new_item) is not type(item):
|
|
1270
|
+
raise TypeError(
|
|
1271
|
+
f"invalid modification of the display list: "
|
|
1272
|
+
f"expected {type(item).__name__}, got {type(new_item).__name__}"
|
|
1273
|
+
)
|
|
1274
|
+
new_items.append(new_item)
|
|
1275
|
+
|
|
1276
|
+
# Phase 2: assign
|
|
1277
|
+
dl.clear()
|
|
1278
|
+
dl.extend(new_items)
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def grid_locator(
|
|
1282
|
+
unit: str = "native",
|
|
1283
|
+
x_device: Optional[float] = None,
|
|
1284
|
+
y_device: Optional[float] = None,
|
|
1285
|
+
) -> Optional[Dict[str, float]]:
|
|
1286
|
+
"""Convert device coordinates to grid coordinates in the current viewport.
|
|
1287
|
+
|
|
1288
|
+
Equivalent to R's ``grid.locator(unit)``. In R, the function waits
|
|
1289
|
+
for a mouse click on an interactive device. In Python, since we use
|
|
1290
|
+
file-based Cairo rendering, device coordinates are passed explicitly
|
|
1291
|
+
via *x_device* and *y_device*.
|
|
1292
|
+
|
|
1293
|
+
The conversion uses the current viewport's coordinate transform,
|
|
1294
|
+
matching R's approach of applying ``solve(current.transform())``
|
|
1295
|
+
to the raw device coordinates.
|
|
1296
|
+
|
|
1297
|
+
Mirrors ``grid.locator`` in R (``grid/R/interactive.R``).
|
|
1298
|
+
|
|
1299
|
+
Parameters
|
|
1300
|
+
----------
|
|
1301
|
+
unit : str, optional
|
|
1302
|
+
Target unit for the returned coordinates (default ``"native"``).
|
|
1303
|
+
Common values: ``"native"``, ``"npc"``, ``"cm"``, ``"inches"``,
|
|
1304
|
+
``"points"``.
|
|
1305
|
+
x_device : float or None
|
|
1306
|
+
X coordinate in device pixels (0 = left edge).
|
|
1307
|
+
y_device : float or None
|
|
1308
|
+
Y coordinate in device pixels (0 = top edge).
|
|
1309
|
+
|
|
1310
|
+
Returns
|
|
1311
|
+
-------
|
|
1312
|
+
dict or None
|
|
1313
|
+
``{"x": <value>, "y": <value>}`` in the requested unit,
|
|
1314
|
+
or ``None`` if coordinates are not provided.
|
|
1315
|
+
|
|
1316
|
+
Examples
|
|
1317
|
+
--------
|
|
1318
|
+
>>> grid_locator("npc", x_device=200, y_device=150)
|
|
1319
|
+
{"x": 0.45, "y": 0.62}
|
|
1320
|
+
"""
|
|
1321
|
+
if x_device is None or y_device is None:
|
|
1322
|
+
import warnings
|
|
1323
|
+
warnings.warn(
|
|
1324
|
+
"grid.locator() is not supported in non-interactive mode; "
|
|
1325
|
+
"pass x_device and y_device explicitly.",
|
|
1326
|
+
stacklevel=2,
|
|
1327
|
+
)
|
|
1328
|
+
return None
|
|
1329
|
+
|
|
1330
|
+
state = get_state()
|
|
1331
|
+
renderer = state.get_renderer()
|
|
1332
|
+
if renderer is None:
|
|
1333
|
+
return None
|
|
1334
|
+
|
|
1335
|
+
# Current viewport bounds in device coords: (x0, y0, pw, ph)
|
|
1336
|
+
x0, y0, pw, ph = renderer.get_viewport_bounds()
|
|
1337
|
+
if pw == 0 or ph == 0:
|
|
1338
|
+
return None
|
|
1339
|
+
|
|
1340
|
+
# Device coords → NPC within current viewport
|
|
1341
|
+
# _x(npc) = x0 + npc * pw → npc = (x_device - x0) / pw
|
|
1342
|
+
# _y(npc) = y0 + (1-npc)*ph → npc = 1 - (y_device - y0) / ph
|
|
1343
|
+
npc_x = (float(x_device) - x0) / pw
|
|
1344
|
+
npc_y = 1.0 - (float(y_device) - y0) / ph
|
|
1345
|
+
|
|
1346
|
+
if unit == "npc":
|
|
1347
|
+
return {"x": npc_x, "y": npc_y}
|
|
1348
|
+
|
|
1349
|
+
# NPC → inches (via viewport device size and DPI)
|
|
1350
|
+
x_inches = npc_x * pw / renderer.dpi
|
|
1351
|
+
y_inches = npc_y * ph / renderer.dpi
|
|
1352
|
+
|
|
1353
|
+
if unit == "inches":
|
|
1354
|
+
return {"x": x_inches, "y": y_inches}
|
|
1355
|
+
elif unit == "cm":
|
|
1356
|
+
return {"x": x_inches * 2.54, "y": y_inches * 2.54}
|
|
1357
|
+
elif unit == "mm":
|
|
1358
|
+
return {"x": x_inches * 25.4, "y": y_inches * 25.4}
|
|
1359
|
+
elif unit in ("points", "pt"):
|
|
1360
|
+
return {"x": x_inches * 72.0, "y": y_inches * 72.0}
|
|
1361
|
+
elif unit == "native":
|
|
1362
|
+
vp = state.current_viewport()
|
|
1363
|
+
if vp is not None:
|
|
1364
|
+
xscale = getattr(vp, "xscale", [0, 1])
|
|
1365
|
+
yscale = getattr(vp, "yscale", [0, 1])
|
|
1366
|
+
if hasattr(xscale, '__len__') and len(xscale) >= 2:
|
|
1367
|
+
x_native = xscale[0] + npc_x * (xscale[1] - xscale[0])
|
|
1368
|
+
else:
|
|
1369
|
+
x_native = npc_x
|
|
1370
|
+
if hasattr(yscale, '__len__') and len(yscale) >= 2:
|
|
1371
|
+
y_native = yscale[0] + npc_y * (yscale[1] - yscale[0])
|
|
1372
|
+
else:
|
|
1373
|
+
y_native = npc_y
|
|
1374
|
+
return {"x": x_native, "y": y_native}
|
|
1375
|
+
return {"x": npc_x, "y": npc_y}
|
|
1376
|
+
else:
|
|
1377
|
+
return {"x": npc_x, "y": npc_y}
|
|
1378
|
+
|
|
1379
|
+
|
|
1380
|
+
def grid_pretty(
|
|
1381
|
+
range_val: Sequence[float],
|
|
1382
|
+
) -> np.ndarray:
|
|
1383
|
+
"""Return pretty tick positions for a numeric range.
|
|
1384
|
+
|
|
1385
|
+
This is a thin wrapper around :func:`._utils.grid_pretty`.
|
|
1386
|
+
|
|
1387
|
+
Parameters
|
|
1388
|
+
----------
|
|
1389
|
+
range_val : sequence of float
|
|
1390
|
+
A two-element sequence ``[min, max]`` defining the range.
|
|
1391
|
+
|
|
1392
|
+
Returns
|
|
1393
|
+
-------
|
|
1394
|
+
numpy.ndarray
|
|
1395
|
+
An array of tick positions.
|
|
1396
|
+
"""
|
|
1397
|
+
return _grid_pretty(range_val)
|