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/_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)