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/_coords.py
ADDED
|
@@ -0,0 +1,1534 @@
|
|
|
1
|
+
"""Coordinate query system for grid_py (port of R's ``grid/R/coords.R``).
|
|
2
|
+
|
|
3
|
+
This module provides data structures and functions for computing sets of points
|
|
4
|
+
around the perimeter (or along the length) of grobs. The three container
|
|
5
|
+
classes -- :class:`GridCoords`, :class:`GridGrobCoords`, and
|
|
6
|
+
:class:`GridGTreeCoords` -- mirror R's ``GridCoords``, ``GridGrobCoords``,
|
|
7
|
+
and ``GridGTreeCoords`` S3 classes respectively.
|
|
8
|
+
|
|
9
|
+
``grob_coords`` is the user-level entry point that emulates drawing set-up
|
|
10
|
+
behaviour (pushing viewports, setting graphical parameters).
|
|
11
|
+
``grob_points`` skips that set-up and is intended for internal use when the
|
|
12
|
+
drawing context has already been established.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import (
|
|
18
|
+
Any,
|
|
19
|
+
Dict,
|
|
20
|
+
Iterator,
|
|
21
|
+
List,
|
|
22
|
+
Optional,
|
|
23
|
+
Sequence,
|
|
24
|
+
Union,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
from numpy.typing import ArrayLike
|
|
29
|
+
|
|
30
|
+
from ._grob import Grob, GTree, GList
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"GridCoords",
|
|
34
|
+
"GridGrobCoords",
|
|
35
|
+
"GridGTreeCoords",
|
|
36
|
+
"grob_coords",
|
|
37
|
+
"grob_points",
|
|
38
|
+
"grid_coords",
|
|
39
|
+
"grid_grob_coords",
|
|
40
|
+
"grid_gtree_coords",
|
|
41
|
+
"empty_coords",
|
|
42
|
+
"empty_grob_coords",
|
|
43
|
+
"empty_gtree_coords",
|
|
44
|
+
"is_empty_coords",
|
|
45
|
+
"coords_bbox",
|
|
46
|
+
"is_closed",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Print indentation (mirrors R's ``coordPrintIndent``)
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
_COORD_PRINT_INDENT: str = " "
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# GridCoords -- low-level coordinate pair container
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class GridCoords:
|
|
62
|
+
"""Container for a set of (x, y) coordinate pairs.
|
|
63
|
+
|
|
64
|
+
This is the leaf node in the coordinate hierarchy. It wraps two
|
|
65
|
+
equal-length NumPy arrays of x and y values (typically in inches).
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
x : array_like
|
|
70
|
+
X coordinates.
|
|
71
|
+
y : array_like
|
|
72
|
+
Y coordinates.
|
|
73
|
+
name : str
|
|
74
|
+
Human-readable label for this coordinate set.
|
|
75
|
+
|
|
76
|
+
Raises
|
|
77
|
+
------
|
|
78
|
+
ValueError
|
|
79
|
+
If *x* and *y* do not have the same length.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
x: ArrayLike,
|
|
85
|
+
y: ArrayLike,
|
|
86
|
+
name: str = "coords",
|
|
87
|
+
) -> None:
|
|
88
|
+
x_arr = np.asarray(x, dtype=float)
|
|
89
|
+
y_arr = np.asarray(y, dtype=float)
|
|
90
|
+
# Ensure 1-D
|
|
91
|
+
x_arr = np.atleast_1d(x_arr)
|
|
92
|
+
y_arr = np.atleast_1d(y_arr)
|
|
93
|
+
if x_arr.shape != y_arr.shape:
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"x and y must have the same shape, got {x_arr.shape} and {y_arr.shape}"
|
|
96
|
+
)
|
|
97
|
+
self._x = x_arr
|
|
98
|
+
self._y = y_arr
|
|
99
|
+
self._name = name
|
|
100
|
+
|
|
101
|
+
# -- properties ---------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def x(self) -> np.ndarray:
|
|
105
|
+
"""X coordinates as a NumPy array."""
|
|
106
|
+
return self._x
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def y(self) -> np.ndarray:
|
|
110
|
+
"""Y coordinates as a NumPy array."""
|
|
111
|
+
return self._y
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def name(self) -> str:
|
|
115
|
+
"""Human-readable label."""
|
|
116
|
+
return self._name
|
|
117
|
+
|
|
118
|
+
# -- query methods ------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def get_x(self) -> np.ndarray:
|
|
121
|
+
"""Return the x coordinate array.
|
|
122
|
+
|
|
123
|
+
Returns
|
|
124
|
+
-------
|
|
125
|
+
numpy.ndarray
|
|
126
|
+
The x values.
|
|
127
|
+
"""
|
|
128
|
+
return self._x
|
|
129
|
+
|
|
130
|
+
def get_y(self) -> np.ndarray:
|
|
131
|
+
"""Return the y coordinate array.
|
|
132
|
+
|
|
133
|
+
Returns
|
|
134
|
+
-------
|
|
135
|
+
numpy.ndarray
|
|
136
|
+
The y values.
|
|
137
|
+
"""
|
|
138
|
+
return self._y
|
|
139
|
+
|
|
140
|
+
# -- transformation methods ---------------------------------------------
|
|
141
|
+
|
|
142
|
+
def to_device(self, state: Any = None) -> "GridCoords":
|
|
143
|
+
"""Convert coordinates to device space.
|
|
144
|
+
|
|
145
|
+
Parameters
|
|
146
|
+
----------
|
|
147
|
+
state : object, optional
|
|
148
|
+
Device / viewport state used for unit conversion. When ``None``
|
|
149
|
+
the coordinates are returned unchanged (no-op).
|
|
150
|
+
|
|
151
|
+
Returns
|
|
152
|
+
-------
|
|
153
|
+
GridCoords
|
|
154
|
+
New instance with device-space coordinates.
|
|
155
|
+
"""
|
|
156
|
+
if self.is_empty():
|
|
157
|
+
return self
|
|
158
|
+
if state is None:
|
|
159
|
+
return self
|
|
160
|
+
# If a state object provides a ``device_loc`` method, use it.
|
|
161
|
+
if hasattr(state, "device_loc"):
|
|
162
|
+
dx, dy = state.device_loc(self._x, self._y)
|
|
163
|
+
return GridCoords(dx, dy, name=self._name)
|
|
164
|
+
return self
|
|
165
|
+
|
|
166
|
+
def from_device(self, state: Any = None) -> "GridCoords":
|
|
167
|
+
"""Transform coordinates from device space using an inverse transform.
|
|
168
|
+
|
|
169
|
+
In R this multiplies the coordinate matrix by ``solve(trans)``.
|
|
170
|
+
|
|
171
|
+
Parameters
|
|
172
|
+
----------
|
|
173
|
+
state : object, optional
|
|
174
|
+
A 3x3 transformation matrix (ndarray) **or** an object with a
|
|
175
|
+
``transform`` attribute that is a 3x3 matrix. When ``None`` the
|
|
176
|
+
coordinates are returned unchanged.
|
|
177
|
+
|
|
178
|
+
Returns
|
|
179
|
+
-------
|
|
180
|
+
GridCoords
|
|
181
|
+
New instance with transformed coordinates.
|
|
182
|
+
"""
|
|
183
|
+
if state is None:
|
|
184
|
+
return self
|
|
185
|
+
trans = state
|
|
186
|
+
if hasattr(state, "transform"):
|
|
187
|
+
trans = state.transform
|
|
188
|
+
trans = np.asarray(trans, dtype=float)
|
|
189
|
+
if trans.shape != (3, 3):
|
|
190
|
+
raise ValueError("from_device requires a 3x3 transformation matrix")
|
|
191
|
+
# R coords.R:183: cbind(x,y,1) %*% solve(trans) → pts @ inv(trans)
|
|
192
|
+
inv = np.linalg.solve(trans, np.eye(3))
|
|
193
|
+
pts = np.column_stack([self._x, self._y, np.ones(len(self._x))])
|
|
194
|
+
result = pts @ inv
|
|
195
|
+
return GridCoords(result[:, 0], result[:, 1], name=self._name)
|
|
196
|
+
|
|
197
|
+
def is_empty(self) -> bool:
|
|
198
|
+
"""Return ``True`` when this represents the canonical empty coords.
|
|
199
|
+
|
|
200
|
+
Returns
|
|
201
|
+
-------
|
|
202
|
+
bool
|
|
203
|
+
"""
|
|
204
|
+
return (
|
|
205
|
+
len(self._x) == 1
|
|
206
|
+
and self._x[0] == 0.0
|
|
207
|
+
and self._y[0] == 0.0
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def transform_coords(self, tm: np.ndarray) -> "GridCoords":
|
|
211
|
+
"""Apply a 3x3 affine transformation matrix.
|
|
212
|
+
|
|
213
|
+
Parameters
|
|
214
|
+
----------
|
|
215
|
+
tm : numpy.ndarray
|
|
216
|
+
A 3x3 affine transformation matrix.
|
|
217
|
+
|
|
218
|
+
Returns
|
|
219
|
+
-------
|
|
220
|
+
GridCoords
|
|
221
|
+
Transformed coordinates.
|
|
222
|
+
"""
|
|
223
|
+
tm = np.asarray(tm, dtype=float)
|
|
224
|
+
pts = np.column_stack([self._x, self._y, np.ones(len(self._x))])
|
|
225
|
+
result = pts @ tm
|
|
226
|
+
return GridCoords(result[:, 0], result[:, 1], name=self._name)
|
|
227
|
+
|
|
228
|
+
def flatten(self) -> "GridCoords":
|
|
229
|
+
"""Return a flattened copy (no-op for leaf nodes).
|
|
230
|
+
|
|
231
|
+
Returns
|
|
232
|
+
-------
|
|
233
|
+
GridCoords
|
|
234
|
+
"""
|
|
235
|
+
return GridCoords(self._x.copy(), self._y.copy(), name=self._name)
|
|
236
|
+
|
|
237
|
+
# -- dunder methods -----------------------------------------------------
|
|
238
|
+
|
|
239
|
+
def __len__(self) -> int:
|
|
240
|
+
return len(self._x)
|
|
241
|
+
|
|
242
|
+
def __repr__(self) -> str:
|
|
243
|
+
def _fmt(arr: np.ndarray) -> str:
|
|
244
|
+
if len(arr) > 3:
|
|
245
|
+
head = " ".join(f"{v:.4g}" for v in arr[:3])
|
|
246
|
+
return f"{head} ... [{len(arr)} values]"
|
|
247
|
+
return " ".join(f"{v:.4g}" for v in arr) + f" [{len(arr)} values]"
|
|
248
|
+
|
|
249
|
+
x_str = _fmt(self._x)
|
|
250
|
+
y_str = _fmt(self._y)
|
|
251
|
+
return f"x: {x_str}\ny: {y_str}"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
# GridGrobCoords -- coordinates for a single grob (list of GridCoords)
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class GridGrobCoords:
|
|
260
|
+
"""Container for coordinates of a single grob.
|
|
261
|
+
|
|
262
|
+
Wraps an ordered list of :class:`GridCoords` (one per sub-shape).
|
|
263
|
+
|
|
264
|
+
Parameters
|
|
265
|
+
----------
|
|
266
|
+
coords_list : list of GridCoords or None
|
|
267
|
+
The coordinate sets for each sub-shape. ``None`` creates an empty
|
|
268
|
+
container.
|
|
269
|
+
name : str
|
|
270
|
+
Name of the parent grob.
|
|
271
|
+
rule : str or None
|
|
272
|
+
Fill rule (``"winding"`` or ``"evenodd"``), mirroring R's
|
|
273
|
+
``attr(x, "rule")``.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
def __init__(
|
|
277
|
+
self,
|
|
278
|
+
coords_list: Optional[List[GridCoords]] = None,
|
|
279
|
+
name: str = "grobcoords",
|
|
280
|
+
rule: Optional[str] = None,
|
|
281
|
+
) -> None:
|
|
282
|
+
self._coords: List[GridCoords] = list(coords_list) if coords_list else []
|
|
283
|
+
self._name = name
|
|
284
|
+
self._rule = rule
|
|
285
|
+
|
|
286
|
+
# -- properties ---------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
@property
|
|
289
|
+
def name(self) -> str:
|
|
290
|
+
"""Name of the parent grob."""
|
|
291
|
+
return self._name
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def rule(self) -> Optional[str]:
|
|
295
|
+
"""Fill rule (``'winding'`` or ``'evenodd'``), or ``None``."""
|
|
296
|
+
return self._rule
|
|
297
|
+
|
|
298
|
+
# -- query methods ------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
def get_x(self, subset: Optional[Sequence[int]] = None) -> np.ndarray:
|
|
301
|
+
"""Return concatenated x values across all (or selected) sub-shapes.
|
|
302
|
+
|
|
303
|
+
Parameters
|
|
304
|
+
----------
|
|
305
|
+
subset : sequence of int, optional
|
|
306
|
+
Indices of sub-shapes to include. ``None`` means all.
|
|
307
|
+
|
|
308
|
+
Returns
|
|
309
|
+
-------
|
|
310
|
+
numpy.ndarray
|
|
311
|
+
"""
|
|
312
|
+
items = self._coords if subset is None else [self._coords[i] for i in subset]
|
|
313
|
+
if not items:
|
|
314
|
+
return np.array([], dtype=float)
|
|
315
|
+
return np.concatenate([c.get_x() for c in items])
|
|
316
|
+
|
|
317
|
+
def get_y(self, subset: Optional[Sequence[int]] = None) -> np.ndarray:
|
|
318
|
+
"""Return concatenated y values across all (or selected) sub-shapes.
|
|
319
|
+
|
|
320
|
+
Parameters
|
|
321
|
+
----------
|
|
322
|
+
subset : sequence of int, optional
|
|
323
|
+
Indices of sub-shapes to include. ``None`` means all.
|
|
324
|
+
|
|
325
|
+
Returns
|
|
326
|
+
-------
|
|
327
|
+
numpy.ndarray
|
|
328
|
+
"""
|
|
329
|
+
items = self._coords if subset is None else [self._coords[i] for i in subset]
|
|
330
|
+
if not items:
|
|
331
|
+
return np.array([], dtype=float)
|
|
332
|
+
return np.concatenate([c.get_y() for c in items])
|
|
333
|
+
|
|
334
|
+
# -- transformation methods ---------------------------------------------
|
|
335
|
+
|
|
336
|
+
def to_device(self, state: Any = None) -> "GridGrobCoords":
|
|
337
|
+
"""Convert all sub-shape coordinates to device space.
|
|
338
|
+
|
|
339
|
+
Parameters
|
|
340
|
+
----------
|
|
341
|
+
state : object, optional
|
|
342
|
+
Passed through to :meth:`GridCoords.to_device`.
|
|
343
|
+
|
|
344
|
+
Returns
|
|
345
|
+
-------
|
|
346
|
+
GridGrobCoords
|
|
347
|
+
"""
|
|
348
|
+
return GridGrobCoords(
|
|
349
|
+
[c.to_device(state) for c in self._coords],
|
|
350
|
+
name=self._name,
|
|
351
|
+
rule=self._rule,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
def from_device(self, state: Any = None) -> "GridGrobCoords":
|
|
355
|
+
"""Transform all sub-shape coordinates from device space.
|
|
356
|
+
|
|
357
|
+
Parameters
|
|
358
|
+
----------
|
|
359
|
+
state : object, optional
|
|
360
|
+
Passed through to :meth:`GridCoords.from_device`.
|
|
361
|
+
|
|
362
|
+
Returns
|
|
363
|
+
-------
|
|
364
|
+
GridGrobCoords
|
|
365
|
+
"""
|
|
366
|
+
return GridGrobCoords(
|
|
367
|
+
[c.from_device(state) for c in self._coords],
|
|
368
|
+
name=self._name,
|
|
369
|
+
rule=self._rule,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def is_empty(self) -> bool:
|
|
373
|
+
"""Return ``True`` when every sub-shape is empty.
|
|
374
|
+
|
|
375
|
+
Returns
|
|
376
|
+
-------
|
|
377
|
+
bool
|
|
378
|
+
"""
|
|
379
|
+
if not self._coords:
|
|
380
|
+
return True
|
|
381
|
+
return all(c.is_empty() for c in self._coords)
|
|
382
|
+
|
|
383
|
+
def transform_coords(self, tm: np.ndarray) -> "GridGrobCoords":
|
|
384
|
+
"""Apply an affine transformation to all sub-shapes.
|
|
385
|
+
|
|
386
|
+
Parameters
|
|
387
|
+
----------
|
|
388
|
+
tm : numpy.ndarray
|
|
389
|
+
A 3x3 affine transformation matrix.
|
|
390
|
+
|
|
391
|
+
Returns
|
|
392
|
+
-------
|
|
393
|
+
GridGrobCoords
|
|
394
|
+
"""
|
|
395
|
+
return GridGrobCoords(
|
|
396
|
+
[c.transform_coords(tm) for c in self._coords],
|
|
397
|
+
name=self._name,
|
|
398
|
+
rule=self._rule,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def flatten(self) -> "GridGrobCoords":
|
|
402
|
+
"""Return a flattened copy.
|
|
403
|
+
|
|
404
|
+
Returns
|
|
405
|
+
-------
|
|
406
|
+
GridGrobCoords
|
|
407
|
+
"""
|
|
408
|
+
return GridGrobCoords(
|
|
409
|
+
[c.flatten() for c in self._coords],
|
|
410
|
+
name=self._name,
|
|
411
|
+
rule=self._rule,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
# -- dunder methods -----------------------------------------------------
|
|
415
|
+
|
|
416
|
+
def __len__(self) -> int:
|
|
417
|
+
return len(self._coords)
|
|
418
|
+
|
|
419
|
+
def __iter__(self) -> Iterator[GridCoords]:
|
|
420
|
+
return iter(self._coords)
|
|
421
|
+
|
|
422
|
+
def __getitem__(self, index: int) -> GridCoords:
|
|
423
|
+
return self._coords[index]
|
|
424
|
+
|
|
425
|
+
def __repr__(self) -> str:
|
|
426
|
+
lines: List[str] = []
|
|
427
|
+
rule_str = f" (fill: {self._rule})" if self._rule else ""
|
|
428
|
+
lines.append(f"grob {self._name}{rule_str}")
|
|
429
|
+
for i, coord in enumerate(self._coords):
|
|
430
|
+
label = str(i + 1)
|
|
431
|
+
lines.append(f"{_COORD_PRINT_INDENT}shape {label}")
|
|
432
|
+
for sub_line in repr(coord).split("\n"):
|
|
433
|
+
lines.append(f"{_COORD_PRINT_INDENT}{_COORD_PRINT_INDENT}{sub_line}")
|
|
434
|
+
return "\n".join(lines)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# ---------------------------------------------------------------------------
|
|
438
|
+
# GridGTreeCoords -- coordinates for a gTree (dict of child coords)
|
|
439
|
+
# ---------------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
class GridGTreeCoords:
|
|
443
|
+
"""Container for coordinates of a gTree.
|
|
444
|
+
|
|
445
|
+
Wraps a mapping from child names to their coordinate containers (which
|
|
446
|
+
may themselves be :class:`GridGrobCoords` or :class:`GridGTreeCoords`).
|
|
447
|
+
|
|
448
|
+
Parameters
|
|
449
|
+
----------
|
|
450
|
+
coords_dict : dict or list or None
|
|
451
|
+
Mapping of child names to coordinate containers, **or** a list of
|
|
452
|
+
coordinate containers (keys are auto-generated). ``None`` creates
|
|
453
|
+
an empty container.
|
|
454
|
+
name : str
|
|
455
|
+
Name of the parent gTree.
|
|
456
|
+
"""
|
|
457
|
+
|
|
458
|
+
def __init__(
|
|
459
|
+
self,
|
|
460
|
+
coords_dict: Union[
|
|
461
|
+
Dict[str, Union[GridGrobCoords, "GridGTreeCoords"]],
|
|
462
|
+
List[Union[GridGrobCoords, "GridGTreeCoords"]],
|
|
463
|
+
None,
|
|
464
|
+
] = None,
|
|
465
|
+
name: str = "gtreecoords",
|
|
466
|
+
) -> None:
|
|
467
|
+
if coords_dict is None:
|
|
468
|
+
self._children: Dict[str, Union[GridGrobCoords, GridGTreeCoords]] = {}
|
|
469
|
+
elif isinstance(coords_dict, dict):
|
|
470
|
+
self._children = dict(coords_dict)
|
|
471
|
+
else:
|
|
472
|
+
# Accept a list -- derive keys from child names or indices
|
|
473
|
+
self._children = {}
|
|
474
|
+
for i, child in enumerate(coords_dict):
|
|
475
|
+
key = getattr(child, "name", str(i))
|
|
476
|
+
self._children[key] = child
|
|
477
|
+
self._name = name
|
|
478
|
+
|
|
479
|
+
# -- properties ---------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
@property
|
|
482
|
+
def name(self) -> str:
|
|
483
|
+
"""Name of the parent gTree."""
|
|
484
|
+
return self._name
|
|
485
|
+
|
|
486
|
+
# -- query methods ------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
def get_x(self) -> np.ndarray:
|
|
489
|
+
"""Return concatenated x values across all children.
|
|
490
|
+
|
|
491
|
+
Returns
|
|
492
|
+
-------
|
|
493
|
+
numpy.ndarray
|
|
494
|
+
"""
|
|
495
|
+
if not self._children:
|
|
496
|
+
return np.array([], dtype=float)
|
|
497
|
+
return np.concatenate([c.get_x() for c in self._children.values()])
|
|
498
|
+
|
|
499
|
+
def get_y(self) -> np.ndarray:
|
|
500
|
+
"""Return concatenated y values across all children.
|
|
501
|
+
|
|
502
|
+
Returns
|
|
503
|
+
-------
|
|
504
|
+
numpy.ndarray
|
|
505
|
+
"""
|
|
506
|
+
if not self._children:
|
|
507
|
+
return np.array([], dtype=float)
|
|
508
|
+
return np.concatenate([c.get_y() for c in self._children.values()])
|
|
509
|
+
|
|
510
|
+
# -- transformation methods ---------------------------------------------
|
|
511
|
+
|
|
512
|
+
def to_device(self, state: Any = None) -> "GridGTreeCoords":
|
|
513
|
+
"""Convert all child coordinates to device space.
|
|
514
|
+
|
|
515
|
+
Parameters
|
|
516
|
+
----------
|
|
517
|
+
state : object, optional
|
|
518
|
+
Passed through to child ``to_device`` methods.
|
|
519
|
+
|
|
520
|
+
Returns
|
|
521
|
+
-------
|
|
522
|
+
GridGTreeCoords
|
|
523
|
+
"""
|
|
524
|
+
return GridGTreeCoords(
|
|
525
|
+
{k: v.to_device(state) for k, v in self._children.items()},
|
|
526
|
+
name=self._name,
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
def from_device(self, state: Any = None) -> "GridGTreeCoords":
|
|
530
|
+
"""Transform all child coordinates from device space.
|
|
531
|
+
|
|
532
|
+
Parameters
|
|
533
|
+
----------
|
|
534
|
+
state : object, optional
|
|
535
|
+
Passed through to child ``from_device`` methods.
|
|
536
|
+
|
|
537
|
+
Returns
|
|
538
|
+
-------
|
|
539
|
+
GridGTreeCoords
|
|
540
|
+
"""
|
|
541
|
+
return GridGTreeCoords(
|
|
542
|
+
{k: v.from_device(state) for k, v in self._children.items()},
|
|
543
|
+
name=self._name,
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
def is_empty(self) -> bool:
|
|
547
|
+
"""Return ``True`` when every child reports empty.
|
|
548
|
+
|
|
549
|
+
Returns
|
|
550
|
+
-------
|
|
551
|
+
bool
|
|
552
|
+
"""
|
|
553
|
+
if not self._children:
|
|
554
|
+
return True
|
|
555
|
+
return all(c.is_empty() for c in self._children.values())
|
|
556
|
+
|
|
557
|
+
def transform_coords(self, tm: np.ndarray) -> "GridGTreeCoords":
|
|
558
|
+
"""Apply an affine transformation to all children.
|
|
559
|
+
|
|
560
|
+
Parameters
|
|
561
|
+
----------
|
|
562
|
+
tm : numpy.ndarray
|
|
563
|
+
A 3x3 affine transformation matrix.
|
|
564
|
+
|
|
565
|
+
Returns
|
|
566
|
+
-------
|
|
567
|
+
GridGTreeCoords
|
|
568
|
+
"""
|
|
569
|
+
return GridGTreeCoords(
|
|
570
|
+
{k: v.transform_coords(tm) for k, v in self._children.items()},
|
|
571
|
+
name=self._name,
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
def flatten(self) -> "GridGTreeCoords":
|
|
575
|
+
"""Return a flattened copy.
|
|
576
|
+
|
|
577
|
+
Returns
|
|
578
|
+
-------
|
|
579
|
+
GridGTreeCoords
|
|
580
|
+
"""
|
|
581
|
+
return GridGTreeCoords(
|
|
582
|
+
{k: v.flatten() for k, v in self._children.items()},
|
|
583
|
+
name=self._name,
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
# -- dunder methods -----------------------------------------------------
|
|
587
|
+
|
|
588
|
+
def __len__(self) -> int:
|
|
589
|
+
return len(self._children)
|
|
590
|
+
|
|
591
|
+
def __iter__(self) -> Iterator[str]:
|
|
592
|
+
return iter(self._children)
|
|
593
|
+
|
|
594
|
+
def __getitem__(self, key: str) -> Union[GridGrobCoords, "GridGTreeCoords"]:
|
|
595
|
+
return self._children[key]
|
|
596
|
+
|
|
597
|
+
def __repr__(self) -> str:
|
|
598
|
+
lines: List[str] = []
|
|
599
|
+
lines.append(f"gTree {self._name}")
|
|
600
|
+
for child in self._children.values():
|
|
601
|
+
for sub_line in repr(child).split("\n"):
|
|
602
|
+
lines.append(f"{_COORD_PRINT_INDENT}{sub_line}")
|
|
603
|
+
return "\n".join(lines)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
# ---------------------------------------------------------------------------
|
|
607
|
+
# Factory / convenience functions
|
|
608
|
+
# ---------------------------------------------------------------------------
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def grid_coords(x: ArrayLike, y: ArrayLike) -> GridCoords:
|
|
612
|
+
"""Create a :class:`GridCoords` instance.
|
|
613
|
+
|
|
614
|
+
This is the public factory function mirroring R's ``gridCoords()``.
|
|
615
|
+
|
|
616
|
+
Parameters
|
|
617
|
+
----------
|
|
618
|
+
x : array_like
|
|
619
|
+
X coordinates.
|
|
620
|
+
y : array_like
|
|
621
|
+
Y coordinates.
|
|
622
|
+
|
|
623
|
+
Returns
|
|
624
|
+
-------
|
|
625
|
+
GridCoords
|
|
626
|
+
"""
|
|
627
|
+
return GridCoords(x, y)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def grid_grob_coords(
|
|
631
|
+
coords_list: List[GridCoords],
|
|
632
|
+
name: str,
|
|
633
|
+
rule: Optional[str] = None,
|
|
634
|
+
) -> GridGrobCoords:
|
|
635
|
+
"""Create a :class:`GridGrobCoords` instance.
|
|
636
|
+
|
|
637
|
+
Parameters
|
|
638
|
+
----------
|
|
639
|
+
coords_list : list of GridCoords
|
|
640
|
+
Coordinate sets for each sub-shape.
|
|
641
|
+
name : str
|
|
642
|
+
Name of the owning grob.
|
|
643
|
+
rule : str or None
|
|
644
|
+
Fill rule.
|
|
645
|
+
|
|
646
|
+
Returns
|
|
647
|
+
-------
|
|
648
|
+
GridGrobCoords
|
|
649
|
+
"""
|
|
650
|
+
return GridGrobCoords(coords_list, name=name, rule=rule)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def grid_gtree_coords(
|
|
654
|
+
coords_dict: Union[
|
|
655
|
+
Dict[str, Union[GridGrobCoords, GridGTreeCoords]],
|
|
656
|
+
List[Union[GridGrobCoords, GridGTreeCoords]],
|
|
657
|
+
],
|
|
658
|
+
name: str,
|
|
659
|
+
) -> GridGTreeCoords:
|
|
660
|
+
"""Create a :class:`GridGTreeCoords` instance.
|
|
661
|
+
|
|
662
|
+
Parameters
|
|
663
|
+
----------
|
|
664
|
+
coords_dict : dict or list
|
|
665
|
+
Mapping (or list) of child coordinate containers.
|
|
666
|
+
name : str
|
|
667
|
+
Name of the owning gTree.
|
|
668
|
+
|
|
669
|
+
Returns
|
|
670
|
+
-------
|
|
671
|
+
GridGTreeCoords
|
|
672
|
+
"""
|
|
673
|
+
return GridGTreeCoords(coords_dict, name=name)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
# ---------------------------------------------------------------------------
|
|
677
|
+
# Canonical empty objects
|
|
678
|
+
# ---------------------------------------------------------------------------
|
|
679
|
+
|
|
680
|
+
#: The canonical "empty" coordinate pair (single (0, 0) point), mirroring
|
|
681
|
+
#: R's ``emptyCoords``.
|
|
682
|
+
_EMPTY_COORDS: GridCoords = GridCoords(np.array([0.0]), np.array([0.0]), name="empty")
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def empty_coords() -> GridCoords:
|
|
686
|
+
"""Return the canonical empty :class:`GridCoords`.
|
|
687
|
+
|
|
688
|
+
Returns
|
|
689
|
+
-------
|
|
690
|
+
GridCoords
|
|
691
|
+
A coordinate set with a single ``(0, 0)`` point.
|
|
692
|
+
"""
|
|
693
|
+
return _EMPTY_COORDS
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def empty_grob_coords(name: str = "empty") -> GridGrobCoords:
|
|
697
|
+
"""Return an empty :class:`GridGrobCoords`.
|
|
698
|
+
|
|
699
|
+
Parameters
|
|
700
|
+
----------
|
|
701
|
+
name : str
|
|
702
|
+
Label for the owning grob.
|
|
703
|
+
|
|
704
|
+
Returns
|
|
705
|
+
-------
|
|
706
|
+
GridGrobCoords
|
|
707
|
+
"""
|
|
708
|
+
return GridGrobCoords([_EMPTY_COORDS], name=name)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def empty_gtree_coords(name: str = "empty") -> GridGTreeCoords:
|
|
712
|
+
"""Return an empty :class:`GridGTreeCoords`.
|
|
713
|
+
|
|
714
|
+
Parameters
|
|
715
|
+
----------
|
|
716
|
+
name : str
|
|
717
|
+
Label for the owning gTree.
|
|
718
|
+
|
|
719
|
+
Returns
|
|
720
|
+
-------
|
|
721
|
+
GridGTreeCoords
|
|
722
|
+
"""
|
|
723
|
+
return GridGTreeCoords([empty_grob_coords("0")], name=name)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def is_empty_coords(x: Union[GridCoords, GridGrobCoords, GridGTreeCoords]) -> bool:
|
|
727
|
+
"""Test whether a coordinate container is the canonical empty set.
|
|
728
|
+
|
|
729
|
+
Dispatches to the ``is_empty()`` method of the container.
|
|
730
|
+
|
|
731
|
+
Parameters
|
|
732
|
+
----------
|
|
733
|
+
x : GridCoords or GridGrobCoords or GridGTreeCoords
|
|
734
|
+
Coordinate container to test.
|
|
735
|
+
|
|
736
|
+
Returns
|
|
737
|
+
-------
|
|
738
|
+
bool
|
|
739
|
+
"""
|
|
740
|
+
return x.is_empty()
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
# ---------------------------------------------------------------------------
|
|
744
|
+
# Bounding box
|
|
745
|
+
# ---------------------------------------------------------------------------
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def coords_bbox(
|
|
749
|
+
x: Union[GridCoords, GridGrobCoords, GridGTreeCoords],
|
|
750
|
+
subset: Optional[Sequence[int]] = None,
|
|
751
|
+
) -> Dict[str, float]:
|
|
752
|
+
"""Compute the axis-aligned bounding box of a coordinate set.
|
|
753
|
+
|
|
754
|
+
Parameters
|
|
755
|
+
----------
|
|
756
|
+
x : GridCoords or GridGrobCoords or GridGTreeCoords
|
|
757
|
+
Coordinate container.
|
|
758
|
+
subset : sequence of int, optional
|
|
759
|
+
Passed to :meth:`GridGrobCoords.get_x` / ``get_y`` when applicable.
|
|
760
|
+
|
|
761
|
+
Returns
|
|
762
|
+
-------
|
|
763
|
+
dict
|
|
764
|
+
Keys ``'left'``, ``'bottom'``, ``'width'``, ``'height'``.
|
|
765
|
+
"""
|
|
766
|
+
if isinstance(x, GridGrobCoords) and subset is not None:
|
|
767
|
+
xx = x.get_x(subset)
|
|
768
|
+
yy = x.get_y(subset)
|
|
769
|
+
else:
|
|
770
|
+
xx = x.get_x()
|
|
771
|
+
yy = x.get_y()
|
|
772
|
+
return {
|
|
773
|
+
"left": float(np.min(xx)),
|
|
774
|
+
"bottom": float(np.min(yy)),
|
|
775
|
+
"width": float(np.ptp(xx)),
|
|
776
|
+
"height": float(np.ptp(yy)),
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
# ---------------------------------------------------------------------------
|
|
781
|
+
# isClosed dispatch
|
|
782
|
+
# ---------------------------------------------------------------------------
|
|
783
|
+
|
|
784
|
+
#: Grob class tags that are considered open (not closed).
|
|
785
|
+
_OPEN_GROB_CLASSES: frozenset[str] = frozenset(
|
|
786
|
+
{
|
|
787
|
+
"move.to",
|
|
788
|
+
"line.to",
|
|
789
|
+
"lines",
|
|
790
|
+
"polyline",
|
|
791
|
+
"segments",
|
|
792
|
+
"beziergrob",
|
|
793
|
+
}
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def is_closed(x: Any) -> bool:
|
|
798
|
+
"""Determine the default ``closed`` value for a grob.
|
|
799
|
+
|
|
800
|
+
Mirrors R's ``isClosed()`` generic. Most grobs default to ``True``;
|
|
801
|
+
line-like grobs default to ``False``.
|
|
802
|
+
|
|
803
|
+
Parameters
|
|
804
|
+
----------
|
|
805
|
+
x : Grob
|
|
806
|
+
A graphical object.
|
|
807
|
+
|
|
808
|
+
Returns
|
|
809
|
+
-------
|
|
810
|
+
bool
|
|
811
|
+
"""
|
|
812
|
+
grid_class = getattr(x, "_grid_class", None) or ""
|
|
813
|
+
if grid_class in _OPEN_GROB_CLASSES:
|
|
814
|
+
return False
|
|
815
|
+
# Special handling for xspline
|
|
816
|
+
if grid_class == "xspline":
|
|
817
|
+
return not getattr(x, "open", True)
|
|
818
|
+
# Special handling for points
|
|
819
|
+
if grid_class == "points":
|
|
820
|
+
pch = getattr(x, "pch", None)
|
|
821
|
+
if pch in (3, 4, 8):
|
|
822
|
+
return False
|
|
823
|
+
return True
|
|
824
|
+
return True
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
# ---------------------------------------------------------------------------
|
|
828
|
+
# grobCoords / grobPoints dispatchers
|
|
829
|
+
# ---------------------------------------------------------------------------
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def grob_coords(x: Any, closed: Optional[bool] = None) -> Union[GridGrobCoords, GridGTreeCoords]:
|
|
833
|
+
"""Get the coordinates of a grob, performing drawing set-up.
|
|
834
|
+
|
|
835
|
+
This is the user-level function that mirrors R's ``grobCoords()``.
|
|
836
|
+
It dispatches based on the grob type: :class:`GList`, :class:`GTree`,
|
|
837
|
+
and plain :class:`Grob` are all handled.
|
|
838
|
+
|
|
839
|
+
Parameters
|
|
840
|
+
----------
|
|
841
|
+
x : Grob or GTree or GList
|
|
842
|
+
A graphical object.
|
|
843
|
+
closed : bool, optional
|
|
844
|
+
Whether to compute closed-shape coordinates. Defaults to the
|
|
845
|
+
result of :func:`is_closed`.
|
|
846
|
+
|
|
847
|
+
Returns
|
|
848
|
+
-------
|
|
849
|
+
GridGrobCoords or GridGTreeCoords
|
|
850
|
+
The computed coordinates.
|
|
851
|
+
"""
|
|
852
|
+
if closed is None:
|
|
853
|
+
closed = is_closed(x)
|
|
854
|
+
|
|
855
|
+
if isinstance(x, GList):
|
|
856
|
+
return _grob_coords_glist(x, closed)
|
|
857
|
+
if isinstance(x, GTree):
|
|
858
|
+
return _grob_coords_gtree(x, closed)
|
|
859
|
+
if isinstance(x, Grob):
|
|
860
|
+
# Check for custom grob_coords method first
|
|
861
|
+
if hasattr(x, "grob_coords"):
|
|
862
|
+
result = x.grob_coords(closed=closed)
|
|
863
|
+
if result is not None:
|
|
864
|
+
return result
|
|
865
|
+
return _grob_coords_grob(x, closed)
|
|
866
|
+
|
|
867
|
+
raise TypeError(f"grob_coords does not support {type(x).__name__}")
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _grob_coords_grob(x: Grob, closed: bool) -> GridGrobCoords:
|
|
871
|
+
"""Compute coordinates for a plain grob.
|
|
872
|
+
|
|
873
|
+
Port of R ``grobCoords.grob`` (coords.R:272-304).
|
|
874
|
+
Does full drawing setup:
|
|
875
|
+
1. Save state (DL, gpar)
|
|
876
|
+
2. preDraw (makeContext + push vp/gp)
|
|
877
|
+
3. makeContent
|
|
878
|
+
4. Call grobPoints to get coordinates
|
|
879
|
+
5. If vp changed: toDevice → postDraw → fromDevice
|
|
880
|
+
6. Otherwise: postDraw
|
|
881
|
+
"""
|
|
882
|
+
import copy
|
|
883
|
+
from ._state import get_state
|
|
884
|
+
|
|
885
|
+
state = get_state()
|
|
886
|
+
renderer = state.get_renderer()
|
|
887
|
+
|
|
888
|
+
# Fallback when no renderer is available (no drawing context)
|
|
889
|
+
if renderer is None:
|
|
890
|
+
return grob_points(x, closed)
|
|
891
|
+
|
|
892
|
+
# Save current transform for fromDevice (R coords.R:274)
|
|
893
|
+
cur_transform = None
|
|
894
|
+
if renderer is not None and hasattr(renderer, "_vp_transform_stack"):
|
|
895
|
+
cur_transform = renderer._vp_transform_stack[-1].transform.copy()
|
|
896
|
+
|
|
897
|
+
original_vp = x.vp
|
|
898
|
+
|
|
899
|
+
# Same setup as drawGrob (R coords.R:276-284)
|
|
900
|
+
saved_dl_on = state._dl_on
|
|
901
|
+
state.set_display_list_on(False)
|
|
902
|
+
saved_gpar = copy.copy(state.get_gpar())
|
|
903
|
+
|
|
904
|
+
try:
|
|
905
|
+
# preDraw: makeContext + push vp/gp + preDrawDetails
|
|
906
|
+
x = x.make_context()
|
|
907
|
+
from ._draw import _push_vp_gp
|
|
908
|
+
_push_vp_gp(x)
|
|
909
|
+
x.pre_draw_details()
|
|
910
|
+
|
|
911
|
+
# makeContent
|
|
912
|
+
x = x.make_content()
|
|
913
|
+
|
|
914
|
+
# Check if vp changed (R coords.R:287)
|
|
915
|
+
vpgrob = (x.vp is not None) or (original_vp is not x.vp)
|
|
916
|
+
|
|
917
|
+
# Get points (R coords.R:290)
|
|
918
|
+
pts = grob_points(x, closed)
|
|
919
|
+
|
|
920
|
+
if vpgrob and not pts.is_empty():
|
|
921
|
+
# Transform to device coordinates (R coords.R:292-294)
|
|
922
|
+
pts = _to_device_grob_coords(pts, state)
|
|
923
|
+
|
|
924
|
+
# postDraw: postDrawDetails + pop vp
|
|
925
|
+
x.post_draw_details()
|
|
926
|
+
if x.vp is not None:
|
|
927
|
+
from ._draw import _pop_grob_vp
|
|
928
|
+
_pop_grob_vp(x.vp)
|
|
929
|
+
|
|
930
|
+
if vpgrob and not pts.is_empty() and cur_transform is not None:
|
|
931
|
+
# Transform back from device (R coords.R:299-301)
|
|
932
|
+
pts = _from_device_grob_coords(pts, cur_transform)
|
|
933
|
+
|
|
934
|
+
finally:
|
|
935
|
+
state.replace_gpar(saved_gpar)
|
|
936
|
+
state.set_display_list_on(saved_dl_on)
|
|
937
|
+
|
|
938
|
+
return pts
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _grob_coords_glist(x: GList, closed: bool) -> GridGTreeCoords:
|
|
942
|
+
"""Compute coordinates for a GList (mirrors R's grobCoords.gList)."""
|
|
943
|
+
from ._grob import grob_name as _grob_name
|
|
944
|
+
|
|
945
|
+
children = [grob_coords(child, closed) for child in x]
|
|
946
|
+
return GridGTreeCoords(children, name=_grob_name())
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _grob_coords_gtree(x: GTree, closed: bool) -> GridGTreeCoords:
|
|
950
|
+
"""Compute coordinates for a gTree.
|
|
951
|
+
|
|
952
|
+
Port of R ``grobCoords.gTree`` (coords.R:312-346).
|
|
953
|
+
Does full drawing setup like drawGTree, then recursively
|
|
954
|
+
calls grobCoords on children.
|
|
955
|
+
"""
|
|
956
|
+
import copy
|
|
957
|
+
from ._state import get_state
|
|
958
|
+
|
|
959
|
+
state = get_state()
|
|
960
|
+
renderer = state.get_renderer()
|
|
961
|
+
|
|
962
|
+
# Fallback when no renderer is available (no drawing context)
|
|
963
|
+
if renderer is None:
|
|
964
|
+
children_order = getattr(x, "_children_order", [])
|
|
965
|
+
children = getattr(x, "_children", {})
|
|
966
|
+
if children and len(children) > 0:
|
|
967
|
+
ordered = [children[k] for k in children_order if k in children]
|
|
968
|
+
pts = [grob_coords(child, closed) for child in ordered]
|
|
969
|
+
return GridGTreeCoords(pts, name=x.name)
|
|
970
|
+
return empty_gtree_coords(x.name)
|
|
971
|
+
|
|
972
|
+
cur_transform = None
|
|
973
|
+
if renderer is not None and hasattr(renderer, "_vp_transform_stack"):
|
|
974
|
+
cur_transform = renderer._vp_transform_stack[-1].transform.copy()
|
|
975
|
+
|
|
976
|
+
original_vp = x.vp
|
|
977
|
+
|
|
978
|
+
# Same setup as drawGTree (R coords.R:316-322)
|
|
979
|
+
saved_dl_on = state._dl_on
|
|
980
|
+
state.set_display_list_on(False)
|
|
981
|
+
saved_gpar = copy.copy(state.get_gpar())
|
|
982
|
+
saved_current_grob = getattr(state, "_current_grob", None)
|
|
983
|
+
|
|
984
|
+
try:
|
|
985
|
+
# preDraw.gTree: makeContext + setCurrentGrob + push vp/gp + childrenvp
|
|
986
|
+
x = x.make_context()
|
|
987
|
+
state._current_grob = x
|
|
988
|
+
|
|
989
|
+
from ._draw import _push_vp_gp
|
|
990
|
+
_push_vp_gp(x)
|
|
991
|
+
|
|
992
|
+
# Push children viewport if present (R grob.R:1792-1801)
|
|
993
|
+
children_vp = getattr(x, "childrenvp", None) or getattr(x, "children_vp", None)
|
|
994
|
+
if children_vp is not None:
|
|
995
|
+
from ._viewport import push_viewport, up_viewport
|
|
996
|
+
from ._draw import _vp_depth
|
|
997
|
+
temp_gp = copy.copy(state.get_gpar())
|
|
998
|
+
push_viewport(children_vp, recording=False)
|
|
999
|
+
up_viewport(_vp_depth(children_vp), recording=False)
|
|
1000
|
+
state.set_gpar(temp_gp)
|
|
1001
|
+
|
|
1002
|
+
x.pre_draw_details()
|
|
1003
|
+
|
|
1004
|
+
# makeContent (R coords.R:327)
|
|
1005
|
+
x = x.make_content()
|
|
1006
|
+
|
|
1007
|
+
# Check if vp changed (R coords.R:330)
|
|
1008
|
+
vpgrob = (x.vp is not None) or (original_vp is not x.vp)
|
|
1009
|
+
|
|
1010
|
+
# Get children coords (R coords.R:332-334)
|
|
1011
|
+
children_order = x._children_order if hasattr(x, "_children_order") else []
|
|
1012
|
+
children = x._children if hasattr(x, "_children") else {}
|
|
1013
|
+
|
|
1014
|
+
if children and len(children) > 0:
|
|
1015
|
+
ordered = [children[k] for k in children_order if k in children]
|
|
1016
|
+
child_pts = [grob_coords(child, closed) for child in ordered]
|
|
1017
|
+
pts = GridGTreeCoords(child_pts, name=x.name)
|
|
1018
|
+
else:
|
|
1019
|
+
pts = empty_gtree_coords(x.name)
|
|
1020
|
+
|
|
1021
|
+
if vpgrob and not pts.is_empty():
|
|
1022
|
+
pts = _to_device_gtree_coords(pts, state)
|
|
1023
|
+
|
|
1024
|
+
# postDraw
|
|
1025
|
+
x.post_draw_details()
|
|
1026
|
+
if x.vp is not None:
|
|
1027
|
+
from ._draw import _pop_grob_vp
|
|
1028
|
+
_pop_grob_vp(x.vp)
|
|
1029
|
+
|
|
1030
|
+
if vpgrob and not pts.is_empty() and cur_transform is not None:
|
|
1031
|
+
pts = _from_device_gtree_coords(pts, cur_transform)
|
|
1032
|
+
|
|
1033
|
+
finally:
|
|
1034
|
+
state.replace_gpar(saved_gpar)
|
|
1035
|
+
state._current_grob = saved_current_grob
|
|
1036
|
+
state.set_display_list_on(saved_dl_on)
|
|
1037
|
+
|
|
1038
|
+
return pts
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
# ---------------------------------------------------------------------------
|
|
1042
|
+
# toDevice / fromDevice helpers for grobCoords
|
|
1043
|
+
# Port of R coords.R:157-195
|
|
1044
|
+
# ---------------------------------------------------------------------------
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _to_device_grob_coords(
|
|
1048
|
+
pts: GridGrobCoords, state: Any
|
|
1049
|
+
) -> GridGrobCoords:
|
|
1050
|
+
"""Convert grob coordinates to device inches via deviceLoc."""
|
|
1051
|
+
from ._units import device_loc, Unit
|
|
1052
|
+
|
|
1053
|
+
new_coords = []
|
|
1054
|
+
for coord in pts:
|
|
1055
|
+
if coord.is_empty():
|
|
1056
|
+
new_coords.append(coord)
|
|
1057
|
+
continue
|
|
1058
|
+
# R coords.R:163-165: deviceLoc(unit(x,"in"), unit(y,"in"), valueOnly=TRUE)
|
|
1059
|
+
result = device_loc(
|
|
1060
|
+
Unit(coord.x, "inches"),
|
|
1061
|
+
Unit(coord.y, "inches"),
|
|
1062
|
+
value_only=True,
|
|
1063
|
+
)
|
|
1064
|
+
new_coords.append(GridCoords(result["x"], result["y"], name=coord.name))
|
|
1065
|
+
return GridGrobCoords(new_coords, name=pts.name, rule=pts.rule)
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def _from_device_grob_coords(
|
|
1069
|
+
pts: GridGrobCoords, transform: np.ndarray
|
|
1070
|
+
) -> GridGrobCoords:
|
|
1071
|
+
"""Transform grob coordinates from device space.
|
|
1072
|
+
|
|
1073
|
+
R coords.R:182-184: cbind(x,y,1) %*% solve(trans)
|
|
1074
|
+
"""
|
|
1075
|
+
inv = np.linalg.solve(transform, np.eye(3))
|
|
1076
|
+
new_coords = []
|
|
1077
|
+
for coord in pts:
|
|
1078
|
+
if coord.is_empty():
|
|
1079
|
+
new_coords.append(coord)
|
|
1080
|
+
continue
|
|
1081
|
+
pts_matrix = np.column_stack([coord.x, coord.y, np.ones(len(coord.x))])
|
|
1082
|
+
result = pts_matrix @ inv
|
|
1083
|
+
new_coords.append(GridCoords(result[:, 0], result[:, 1], name=coord.name))
|
|
1084
|
+
return GridGrobCoords(new_coords, name=pts.name, rule=pts.rule)
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def _to_device_gtree_coords(
|
|
1088
|
+
pts: GridGTreeCoords, state: Any
|
|
1089
|
+
) -> GridGTreeCoords:
|
|
1090
|
+
"""Convert gTree coordinates to device inches."""
|
|
1091
|
+
new_children = {}
|
|
1092
|
+
for key, child in pts._children.items():
|
|
1093
|
+
if isinstance(child, GridGrobCoords):
|
|
1094
|
+
new_children[key] = _to_device_grob_coords(child, state)
|
|
1095
|
+
elif isinstance(child, GridGTreeCoords):
|
|
1096
|
+
new_children[key] = _to_device_gtree_coords(child, state)
|
|
1097
|
+
else:
|
|
1098
|
+
new_children[key] = child
|
|
1099
|
+
return GridGTreeCoords(new_children, name=pts.name)
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _from_device_gtree_coords(
|
|
1103
|
+
pts: GridGTreeCoords, transform: np.ndarray
|
|
1104
|
+
) -> GridGTreeCoords:
|
|
1105
|
+
"""Transform gTree coordinates from device space."""
|
|
1106
|
+
new_children = {}
|
|
1107
|
+
for key, child in pts._children.items():
|
|
1108
|
+
if isinstance(child, GridGrobCoords):
|
|
1109
|
+
new_children[key] = _from_device_grob_coords(child, transform)
|
|
1110
|
+
elif isinstance(child, GridGTreeCoords):
|
|
1111
|
+
new_children[key] = _from_device_gtree_coords(child, transform)
|
|
1112
|
+
else:
|
|
1113
|
+
new_children[key] = child
|
|
1114
|
+
return GridGTreeCoords(new_children, name=pts.name)
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def grob_points(x: Any, closed: Optional[bool] = None) -> GridGrobCoords:
|
|
1118
|
+
"""Get boundary points of a grob without drawing set-up.
|
|
1119
|
+
|
|
1120
|
+
This is for internal use when the drawing context is already
|
|
1121
|
+
established. Mirrors R's ``grobPoints()``.
|
|
1122
|
+
|
|
1123
|
+
Parameters
|
|
1124
|
+
----------
|
|
1125
|
+
x : Grob or GTree or GList
|
|
1126
|
+
A graphical object.
|
|
1127
|
+
closed : bool, optional
|
|
1128
|
+
Whether to compute closed-shape coordinates. Defaults to the
|
|
1129
|
+
result of :func:`is_closed`.
|
|
1130
|
+
|
|
1131
|
+
Returns
|
|
1132
|
+
-------
|
|
1133
|
+
GridGrobCoords or GridGTreeCoords
|
|
1134
|
+
The computed boundary points.
|
|
1135
|
+
"""
|
|
1136
|
+
if closed is None:
|
|
1137
|
+
closed = is_closed(x)
|
|
1138
|
+
|
|
1139
|
+
if isinstance(x, GList):
|
|
1140
|
+
return _grob_points_glist(x, closed)
|
|
1141
|
+
if isinstance(x, GTree):
|
|
1142
|
+
return _grob_points_gtree(x, closed)
|
|
1143
|
+
|
|
1144
|
+
# Dispatch by _grid_class to per-primitive implementations
|
|
1145
|
+
# (port of R coords.R:356-673)
|
|
1146
|
+
grid_class = getattr(x, "_grid_class", None) or ""
|
|
1147
|
+
dispatch_fn = _GROB_POINTS_DISPATCH.get(grid_class)
|
|
1148
|
+
if dispatch_fn is not None:
|
|
1149
|
+
return dispatch_fn(x, closed)
|
|
1150
|
+
|
|
1151
|
+
# Allow grobs to provide their own custom implementation
|
|
1152
|
+
if hasattr(x, "grob_points"):
|
|
1153
|
+
result = x.grob_points(closed=closed)
|
|
1154
|
+
if result is not None:
|
|
1155
|
+
return result
|
|
1156
|
+
|
|
1157
|
+
# Default: return empty
|
|
1158
|
+
name = getattr(x, "name", "unknown")
|
|
1159
|
+
return empty_grob_coords(name)
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _grob_points_glist(x: GList, closed: bool) -> GridGTreeCoords:
|
|
1163
|
+
"""Compute points for a GList (mirrors R's grobPoints.gList)."""
|
|
1164
|
+
from ._grob import grob_name as _grob_name
|
|
1165
|
+
|
|
1166
|
+
if len(x) > 0:
|
|
1167
|
+
return GridGTreeCoords(
|
|
1168
|
+
[grob_coords(child, closed) for child in x],
|
|
1169
|
+
name=_grob_name(),
|
|
1170
|
+
)
|
|
1171
|
+
return empty_gtree_coords(_grob_name())
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def _grob_points_gtree(x: GTree, closed: bool) -> GridGTreeCoords:
|
|
1175
|
+
"""Compute points for a gTree (mirrors R's grobPoints.gTree)."""
|
|
1176
|
+
children_order = getattr(x, "children_order", None)
|
|
1177
|
+
children = getattr(x, "children", None)
|
|
1178
|
+
|
|
1179
|
+
if children is not None and len(children) > 0:
|
|
1180
|
+
if children_order is not None:
|
|
1181
|
+
ordered = [children[k] for k in children_order]
|
|
1182
|
+
else:
|
|
1183
|
+
ordered = list(children)
|
|
1184
|
+
pts = [grob_coords(child, closed) for child in ordered]
|
|
1185
|
+
return GridGTreeCoords(pts, name=x.name)
|
|
1186
|
+
return empty_gtree_coords(x.name)
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
# ---------------------------------------------------------------------------
|
|
1190
|
+
# Per-primitive grobPoints implementations
|
|
1191
|
+
# Port of R coords.R:356-673
|
|
1192
|
+
# ---------------------------------------------------------------------------
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def _grob_points_circle(x: Grob, closed: bool = True, n: int = 100) -> GridGrobCoords:
|
|
1196
|
+
"""grobPoints.circle -- R coords.R:368-388.
|
|
1197
|
+
|
|
1198
|
+
Returns n points around each circle perimeter (closed=True)
|
|
1199
|
+
or empty (closed=False).
|
|
1200
|
+
"""
|
|
1201
|
+
if not closed:
|
|
1202
|
+
return empty_grob_coords(x.name)
|
|
1203
|
+
|
|
1204
|
+
from ._units import convert_x, convert_y, convert_width, convert_height
|
|
1205
|
+
|
|
1206
|
+
cx = convert_x(x.x, "inches", valueOnly=True)
|
|
1207
|
+
cy = convert_y(x.y, "inches", valueOnly=True)
|
|
1208
|
+
# R: r = pmin(convertWidth(r), convertHeight(r))
|
|
1209
|
+
rw = convert_width(x.r, "inches", valueOnly=True)
|
|
1210
|
+
rh = convert_height(x.r, "inches", valueOnly=True)
|
|
1211
|
+
r = np.minimum(rw, rh)
|
|
1212
|
+
|
|
1213
|
+
t = np.linspace(0, 2 * np.pi, n + 1)[:-1]
|
|
1214
|
+
|
|
1215
|
+
# Recycle via broadcasting (R: cbind(cx, cy, r))
|
|
1216
|
+
data = np.column_stack([
|
|
1217
|
+
np.broadcast_to(cx, max(len(cx), len(cy), len(r))),
|
|
1218
|
+
np.broadcast_to(cy, max(len(cx), len(cy), len(r))),
|
|
1219
|
+
np.broadcast_to(r, max(len(cx), len(cy), len(r))),
|
|
1220
|
+
])
|
|
1221
|
+
ncirc = data.shape[0]
|
|
1222
|
+
pts = []
|
|
1223
|
+
for i in range(ncirc):
|
|
1224
|
+
px = data[i, 0] + data[i, 2] * np.cos(t)
|
|
1225
|
+
py = data[i, 1] + data[i, 2] * np.sin(t)
|
|
1226
|
+
pts.append(GridCoords(px, py, name=str(i + 1)))
|
|
1227
|
+
return GridGrobCoords(pts, name=x.name)
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
def _grob_points_rect(x: Grob, closed: bool = True) -> GridGrobCoords:
|
|
1231
|
+
"""grobPoints.rect -- R coords.R:529-547.
|
|
1232
|
+
|
|
1233
|
+
Returns 4 corners of each rect (closed=True) or empty.
|
|
1234
|
+
"""
|
|
1235
|
+
if not closed:
|
|
1236
|
+
return empty_grob_coords(x.name)
|
|
1237
|
+
|
|
1238
|
+
from ._just import resolve_hjust, resolve_vjust
|
|
1239
|
+
from ._units import convert_x, convert_y, convert_width, convert_height
|
|
1240
|
+
|
|
1241
|
+
just = getattr(x, "just", None) or "centre"
|
|
1242
|
+
hjust = resolve_hjust(just, getattr(x, "hjust", None))
|
|
1243
|
+
vjust = resolve_vjust(just, getattr(x, "vjust", None))
|
|
1244
|
+
|
|
1245
|
+
w = convert_width(x.width, "inches", valueOnly=True)
|
|
1246
|
+
h = convert_height(x.height, "inches", valueOnly=True)
|
|
1247
|
+
xc = convert_x(x.x, "inches", valueOnly=True)
|
|
1248
|
+
yc = convert_y(x.y, "inches", valueOnly=True)
|
|
1249
|
+
|
|
1250
|
+
left = xc - hjust * w
|
|
1251
|
+
bottom = yc - vjust * h
|
|
1252
|
+
right = left + w
|
|
1253
|
+
top = bottom + h
|
|
1254
|
+
|
|
1255
|
+
# Recycle via column_stack (R: cbind(left, right, bottom, top))
|
|
1256
|
+
rects = np.column_stack([
|
|
1257
|
+
np.broadcast_to(left, max(len(left), len(right), len(bottom), len(top))),
|
|
1258
|
+
np.broadcast_to(right, max(len(left), len(right), len(bottom), len(top))),
|
|
1259
|
+
np.broadcast_to(bottom, max(len(left), len(right), len(bottom), len(top))),
|
|
1260
|
+
np.broadcast_to(top, max(len(left), len(right), len(bottom), len(top))),
|
|
1261
|
+
])
|
|
1262
|
+
pts = []
|
|
1263
|
+
for i in range(rects.shape[0]):
|
|
1264
|
+
# R: xyListFromMatrix(rects, c(1,1,2,2), c(3,4,4,3))
|
|
1265
|
+
# Corners: (left,bottom), (left,top), (right,top), (right,bottom)
|
|
1266
|
+
px = np.array([rects[i, 0], rects[i, 0], rects[i, 1], rects[i, 1]])
|
|
1267
|
+
py = np.array([rects[i, 2], rects[i, 3], rects[i, 3], rects[i, 2]])
|
|
1268
|
+
pts.append(GridCoords(px, py, name=str(i + 1)))
|
|
1269
|
+
return GridGrobCoords(pts, name=x.name)
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def _grob_points_lines(x: Grob, closed: bool = False) -> GridGrobCoords:
|
|
1273
|
+
"""grobPoints.lines -- R coords.R:390-400."""
|
|
1274
|
+
if closed:
|
|
1275
|
+
return empty_grob_coords(x.name)
|
|
1276
|
+
|
|
1277
|
+
from ._units import convert_x, convert_y
|
|
1278
|
+
|
|
1279
|
+
xx = convert_x(x.x, "inches", valueOnly=True)
|
|
1280
|
+
yy = convert_y(x.y, "inches", valueOnly=True)
|
|
1281
|
+
# Recycle via column_stack
|
|
1282
|
+
lines = np.column_stack([
|
|
1283
|
+
np.broadcast_to(xx, max(len(xx), len(yy))),
|
|
1284
|
+
np.broadcast_to(yy, max(len(xx), len(yy))),
|
|
1285
|
+
])
|
|
1286
|
+
return GridGrobCoords(
|
|
1287
|
+
[GridCoords(lines[:, 0], lines[:, 1], name="1")],
|
|
1288
|
+
name=x.name,
|
|
1289
|
+
)
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
def _grob_points_polyline(x: Grob, closed: bool = False) -> GridGrobCoords:
|
|
1293
|
+
"""grobPoints.polyline -- R coords.R:402-429."""
|
|
1294
|
+
if closed:
|
|
1295
|
+
return empty_grob_coords(x.name)
|
|
1296
|
+
|
|
1297
|
+
from ._units import convert_x, convert_y
|
|
1298
|
+
|
|
1299
|
+
xx = convert_x(x.x, "inches", valueOnly=True)
|
|
1300
|
+
yy = convert_y(x.y, "inches", valueOnly=True)
|
|
1301
|
+
|
|
1302
|
+
grob_id = getattr(x, "id", None)
|
|
1303
|
+
grob_id_lengths = getattr(x, "id_lengths", None)
|
|
1304
|
+
|
|
1305
|
+
if grob_id is None and grob_id_lengths is None:
|
|
1306
|
+
return GridGrobCoords(
|
|
1307
|
+
[GridCoords(xx, yy, name="1")], name=x.name,
|
|
1308
|
+
)
|
|
1309
|
+
|
|
1310
|
+
if grob_id is None:
|
|
1311
|
+
n = len(grob_id_lengths)
|
|
1312
|
+
grob_id = np.repeat(np.arange(1, n + 1), grob_id_lengths)
|
|
1313
|
+
else:
|
|
1314
|
+
grob_id = np.asarray(grob_id)
|
|
1315
|
+
|
|
1316
|
+
unique_ids = np.unique(grob_id)
|
|
1317
|
+
if len(unique_ids) > 1:
|
|
1318
|
+
pts = []
|
|
1319
|
+
for uid in unique_ids:
|
|
1320
|
+
mask = grob_id == uid
|
|
1321
|
+
pts.append(GridCoords(xx[mask], yy[mask], name=str(uid)))
|
|
1322
|
+
return GridGrobCoords(pts, name=x.name)
|
|
1323
|
+
return GridGrobCoords(
|
|
1324
|
+
[GridCoords(xx, yy, name="1")], name=x.name,
|
|
1325
|
+
)
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def _grob_points_polygon(x: Grob, closed: bool = True) -> GridGrobCoords:
|
|
1329
|
+
"""grobPoints.polygon -- R coords.R:437-464."""
|
|
1330
|
+
if not closed:
|
|
1331
|
+
return empty_grob_coords(x.name)
|
|
1332
|
+
|
|
1333
|
+
from ._units import convert_x, convert_y
|
|
1334
|
+
|
|
1335
|
+
xx = convert_x(x.x, "inches", valueOnly=True)
|
|
1336
|
+
yy = convert_y(x.y, "inches", valueOnly=True)
|
|
1337
|
+
|
|
1338
|
+
grob_id = getattr(x, "id", None)
|
|
1339
|
+
grob_id_lengths = getattr(x, "id_lengths", None)
|
|
1340
|
+
|
|
1341
|
+
if grob_id is None and grob_id_lengths is None:
|
|
1342
|
+
return GridGrobCoords(
|
|
1343
|
+
[GridCoords(xx, yy, name="1")], name=x.name,
|
|
1344
|
+
)
|
|
1345
|
+
|
|
1346
|
+
if grob_id is None:
|
|
1347
|
+
n = len(grob_id_lengths)
|
|
1348
|
+
grob_id = np.repeat(np.arange(1, n + 1), grob_id_lengths)
|
|
1349
|
+
else:
|
|
1350
|
+
grob_id = np.asarray(grob_id)
|
|
1351
|
+
|
|
1352
|
+
unique_ids = np.unique(grob_id)
|
|
1353
|
+
if len(unique_ids) > 1:
|
|
1354
|
+
pts = []
|
|
1355
|
+
for uid in unique_ids:
|
|
1356
|
+
mask = grob_id == uid
|
|
1357
|
+
pts.append(GridCoords(xx[mask], yy[mask], name=str(uid)))
|
|
1358
|
+
return GridGrobCoords(pts, name=x.name)
|
|
1359
|
+
return GridGrobCoords(
|
|
1360
|
+
[GridCoords(xx, yy, name="1")], name=x.name,
|
|
1361
|
+
)
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def _grob_points_segments(x: Grob, closed: bool = False) -> GridGrobCoords:
|
|
1365
|
+
"""grobPoints.segments -- R coords.R:549-563."""
|
|
1366
|
+
if closed:
|
|
1367
|
+
return empty_grob_coords(x.name)
|
|
1368
|
+
|
|
1369
|
+
from ._units import convert_x, convert_y
|
|
1370
|
+
|
|
1371
|
+
x0 = convert_x(x.x0, "inches", valueOnly=True)
|
|
1372
|
+
x1 = convert_x(x.x1, "inches", valueOnly=True)
|
|
1373
|
+
y0 = convert_y(x.y0, "inches", valueOnly=True)
|
|
1374
|
+
y1 = convert_y(x.y1, "inches", valueOnly=True)
|
|
1375
|
+
|
|
1376
|
+
# Recycle via column_stack (R: cbind(x0, x1, y0, y1))
|
|
1377
|
+
maxlen = max(len(x0), len(x1), len(y0), len(y1))
|
|
1378
|
+
xy = np.column_stack([
|
|
1379
|
+
np.broadcast_to(x0, maxlen),
|
|
1380
|
+
np.broadcast_to(x1, maxlen),
|
|
1381
|
+
np.broadcast_to(y0, maxlen),
|
|
1382
|
+
np.broadcast_to(y1, maxlen),
|
|
1383
|
+
])
|
|
1384
|
+
pts = []
|
|
1385
|
+
for i in range(xy.shape[0]):
|
|
1386
|
+
# R: xyListFromMatrix(xy, 1:2, 3:4)
|
|
1387
|
+
px = np.array([xy[i, 0], xy[i, 1]])
|
|
1388
|
+
py = np.array([xy[i, 2], xy[i, 3]])
|
|
1389
|
+
pts.append(GridCoords(px, py, name=str(i + 1)))
|
|
1390
|
+
return GridGrobCoords(pts, name=x.name)
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
def _grob_points_pathgrob(x: Grob, closed: bool = True) -> GridGrobCoords:
|
|
1394
|
+
"""grobPoints.pathgrob -- R coords.R:474-527.
|
|
1395
|
+
|
|
1396
|
+
Handles pathId and id for multiple paths/shapes.
|
|
1397
|
+
"""
|
|
1398
|
+
if not closed:
|
|
1399
|
+
return empty_grob_coords(x.name)
|
|
1400
|
+
|
|
1401
|
+
from ._units import convert_x, convert_y
|
|
1402
|
+
|
|
1403
|
+
xx = convert_x(x.x, "inches", valueOnly=True)
|
|
1404
|
+
yy = convert_y(x.y, "inches", valueOnly=True)
|
|
1405
|
+
rule = getattr(x, "rule", None)
|
|
1406
|
+
|
|
1407
|
+
grob_id = getattr(x, "id", None)
|
|
1408
|
+
grob_id_lengths = getattr(x, "id_lengths", None)
|
|
1409
|
+
|
|
1410
|
+
if grob_id is None and grob_id_lengths is None:
|
|
1411
|
+
return GridGrobCoords(
|
|
1412
|
+
[GridCoords(xx, yy, name="1")],
|
|
1413
|
+
name=x.name, rule=rule,
|
|
1414
|
+
)
|
|
1415
|
+
|
|
1416
|
+
if grob_id is None:
|
|
1417
|
+
n = len(grob_id_lengths)
|
|
1418
|
+
grob_id = np.repeat(np.arange(1, n + 1), grob_id_lengths)
|
|
1419
|
+
else:
|
|
1420
|
+
grob_id = np.asarray(grob_id)
|
|
1421
|
+
|
|
1422
|
+
unique_ids = np.unique(grob_id)
|
|
1423
|
+
pts = []
|
|
1424
|
+
for uid in unique_ids:
|
|
1425
|
+
mask = grob_id == uid
|
|
1426
|
+
pts.append(GridCoords(xx[mask], yy[mask], name=str(uid)))
|
|
1427
|
+
return GridGrobCoords(pts, name=x.name, rule=rule)
|
|
1428
|
+
|
|
1429
|
+
|
|
1430
|
+
def _grob_points_text(x: Grob, closed: bool = True) -> GridGrobCoords:
|
|
1431
|
+
"""grobPoints.text -- R coords.R:591-612.
|
|
1432
|
+
|
|
1433
|
+
Returns bounding box (4 corners) if closed, empty otherwise.
|
|
1434
|
+
Uses string metrics to estimate the text bounds.
|
|
1435
|
+
"""
|
|
1436
|
+
if not closed:
|
|
1437
|
+
return empty_grob_coords(x.name)
|
|
1438
|
+
|
|
1439
|
+
from ._just import resolve_hjust, resolve_vjust
|
|
1440
|
+
from ._units import convert_x, convert_y, Unit
|
|
1441
|
+
from ._font_metrics import get_font_backend
|
|
1442
|
+
|
|
1443
|
+
label = getattr(x, "label", "")
|
|
1444
|
+
if isinstance(label, (list, tuple)):
|
|
1445
|
+
label = label[0] if label else ""
|
|
1446
|
+
|
|
1447
|
+
just = getattr(x, "just", None) or "centre"
|
|
1448
|
+
hjust = resolve_hjust(just, getattr(x, "hjust", None))
|
|
1449
|
+
vjust = resolve_vjust(just, getattr(x, "vjust", None))
|
|
1450
|
+
|
|
1451
|
+
# Get text position in inches
|
|
1452
|
+
xpos = convert_x(x.x, "inches", valueOnly=True)
|
|
1453
|
+
ypos = convert_y(x.y, "inches", valueOnly=True)
|
|
1454
|
+
|
|
1455
|
+
# Measure text
|
|
1456
|
+
backend = get_font_backend()
|
|
1457
|
+
gp = getattr(x, "gp", None)
|
|
1458
|
+
metric = backend.measure(str(label), gp)
|
|
1459
|
+
|
|
1460
|
+
w = metric.get("width", 0.0)
|
|
1461
|
+
asc = metric.get("ascent", 0.0)
|
|
1462
|
+
desc = metric.get("descent", 0.0)
|
|
1463
|
+
h = asc + desc
|
|
1464
|
+
|
|
1465
|
+
if w == 0 and h == 0:
|
|
1466
|
+
return empty_grob_coords(x.name)
|
|
1467
|
+
|
|
1468
|
+
# Compute bounds based on justification
|
|
1469
|
+
left = float(xpos[0]) - hjust * w
|
|
1470
|
+
bottom = float(ypos[0]) - vjust * h
|
|
1471
|
+
right = left + w
|
|
1472
|
+
top = bottom + h
|
|
1473
|
+
|
|
1474
|
+
return GridGrobCoords(
|
|
1475
|
+
[GridCoords(
|
|
1476
|
+
np.array([left, left, right, right]),
|
|
1477
|
+
np.array([bottom, top, top, bottom]),
|
|
1478
|
+
name="1",
|
|
1479
|
+
)],
|
|
1480
|
+
name=x.name,
|
|
1481
|
+
)
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
def _grob_points_xspline(x: Grob, closed: Optional[bool] = None) -> GridGrobCoords:
|
|
1485
|
+
"""grobPoints.xspline -- R coords.R:565-586.
|
|
1486
|
+
|
|
1487
|
+
Returns traced spline points. Falls back to control points if
|
|
1488
|
+
the spline tracing function is unavailable.
|
|
1489
|
+
"""
|
|
1490
|
+
is_open = getattr(x, "open", True)
|
|
1491
|
+
if closed is None:
|
|
1492
|
+
closed = not is_open
|
|
1493
|
+
|
|
1494
|
+
if (closed and not is_open) or (not closed and is_open):
|
|
1495
|
+
from ._units import convert_x, convert_y
|
|
1496
|
+
xx = convert_x(x.x, "inches", valueOnly=True)
|
|
1497
|
+
yy = convert_y(x.y, "inches", valueOnly=True)
|
|
1498
|
+
return GridGrobCoords(
|
|
1499
|
+
[GridCoords(xx, yy, name="1")],
|
|
1500
|
+
name=x.name,
|
|
1501
|
+
)
|
|
1502
|
+
return empty_grob_coords(x.name)
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
def _grob_points_rastergrob(x: Grob, closed: bool = True) -> GridGrobCoords:
|
|
1506
|
+
"""grobPoints.rastergrob -- R coords.R:639-641. Always empty."""
|
|
1507
|
+
return empty_grob_coords(x.name)
|
|
1508
|
+
|
|
1509
|
+
|
|
1510
|
+
def _grob_points_null(x: Grob, closed: bool = True) -> GridGrobCoords:
|
|
1511
|
+
"""grobPoints.null -- R coords.R:647-649. Always empty."""
|
|
1512
|
+
return empty_grob_coords(x.name)
|
|
1513
|
+
|
|
1514
|
+
|
|
1515
|
+
# ---------------------------------------------------------------------------
|
|
1516
|
+
# Dispatcher: map _grid_class → grobPoints implementation
|
|
1517
|
+
# ---------------------------------------------------------------------------
|
|
1518
|
+
|
|
1519
|
+
_GROB_POINTS_DISPATCH: Dict[str, Any] = {
|
|
1520
|
+
"circle": _grob_points_circle,
|
|
1521
|
+
"rect": _grob_points_rect,
|
|
1522
|
+
"lines": _grob_points_lines,
|
|
1523
|
+
"polyline": _grob_points_polyline,
|
|
1524
|
+
"polygon": _grob_points_polygon,
|
|
1525
|
+
"segments": _grob_points_segments,
|
|
1526
|
+
"pathgrob": _grob_points_pathgrob,
|
|
1527
|
+
"text": _grob_points_text,
|
|
1528
|
+
"xspline": _grob_points_xspline,
|
|
1529
|
+
"rastergrob": _grob_points_rastergrob,
|
|
1530
|
+
"null": _grob_points_null,
|
|
1531
|
+
"move.to": lambda x, closed=True: empty_grob_coords(x.name),
|
|
1532
|
+
"line.to": lambda x, closed=True: empty_grob_coords(x.name),
|
|
1533
|
+
"clip": lambda x, closed=True: empty_grob_coords(x.name),
|
|
1534
|
+
}
|