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/_viewport.py ADDED
@@ -0,0 +1,1649 @@
1
+ """Viewport system for grid_py -- Python port of R's ``grid::viewport``.
2
+
3
+ This module provides the :class:`Viewport` class and associated container
4
+ classes (:class:`VpList`, :class:`VpStack`, :class:`VpTree`) that mirror
5
+ the viewport infrastructure in R's *grid* package. It also exposes the
6
+ navigation functions (``push_viewport``, ``pop_viewport``, ``up_viewport``,
7
+ ``down_viewport``, ``seek_viewport``) and query helpers
8
+ (``current_viewport``, ``current_vp_path``, etc.).
9
+
10
+ Viewports define nested rectangular sub-regions of the graphics device,
11
+ each with its own coordinate system, clipping behaviour, and graphical
12
+ parameter settings.
13
+
14
+ References
15
+ ----------
16
+ R source: ``src/library/grid/R/viewport.R``, ``src/library/grid/R/grid.R``
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import copy
22
+ import math
23
+ import threading
24
+ from typing import (
25
+ Any,
26
+ Iterator,
27
+ List,
28
+ Optional,
29
+ Sequence,
30
+ Tuple,
31
+ Union,
32
+ overload,
33
+ )
34
+
35
+ import numpy as np
36
+
37
+ from ._gpar import Gpar
38
+ from ._just import valid_just
39
+ from ._layout import GridLayout
40
+ from ._path import VpPath
41
+ from ._units import Unit, is_unit
42
+
43
+ __all__ = [
44
+ "Viewport",
45
+ "VpList",
46
+ "VpStack",
47
+ "VpTree",
48
+ "push_viewport",
49
+ "pop_viewport",
50
+ "down_viewport",
51
+ "up_viewport",
52
+ "seek_viewport",
53
+ "current_viewport",
54
+ "current_vp_path",
55
+ "current_vp_tree",
56
+ "current_transform",
57
+ "current_rotation",
58
+ "current_parent",
59
+ "data_viewport",
60
+ "plot_viewport",
61
+ "edit_viewport",
62
+ "show_viewport",
63
+ "depth",
64
+ "is_viewport",
65
+ ]
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Module-level auto-name counter (thread-safe)
69
+ # ---------------------------------------------------------------------------
70
+
71
+ _vp_name_lock = threading.Lock()
72
+ _vp_name_index: int = 0
73
+
74
+
75
+ def _vp_auto_name() -> str:
76
+ """Generate a unique viewport name of the form ``GRID.VP.<n>``.
77
+
78
+ Returns
79
+ -------
80
+ str
81
+ The generated name.
82
+
83
+ Notes
84
+ -----
85
+ This mirrors R's ``vpAutoName()`` closure. A module-level counter is
86
+ used instead of a closure so that it can be reset for testing. Access
87
+ is serialised with a lock for thread safety.
88
+ """
89
+ global _vp_name_index
90
+ with _vp_name_lock:
91
+ _vp_name_index += 1
92
+ return f"GRID.VP.{_vp_name_index}"
93
+
94
+
95
+ def _reset_vp_auto_name() -> None:
96
+ """Reset the auto-name counter to zero (for testing)."""
97
+ global _vp_name_index
98
+ with _vp_name_lock:
99
+ _vp_name_index = 0
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Clip value normalisation
104
+ # ---------------------------------------------------------------------------
105
+
106
+ _CLIP_MAP = {
107
+ "on": True,
108
+ "off": None, # R uses NA; we use None
109
+ "inherit": False,
110
+ }
111
+
112
+
113
+ def _valid_clip(clip: Any) -> Any:
114
+ """Normalise *clip* to its internal representation.
115
+
116
+ Parameters
117
+ ----------
118
+ clip : bool, str, or other
119
+ ``"on"`` -> ``True``, ``"off"`` -> ``None``, ``"inherit"`` -> ``False``.
120
+ Booleans and ``None`` pass through unchanged.
121
+
122
+ Returns
123
+ -------
124
+ bool or None
125
+
126
+ Raises
127
+ ------
128
+ ValueError
129
+ If *clip* is a string that is not one of the accepted values.
130
+ """
131
+ if isinstance(clip, bool) or clip is None:
132
+ return clip
133
+ if isinstance(clip, str):
134
+ val = _CLIP_MAP.get(clip.lower())
135
+ if val is None and clip.lower() != "off":
136
+ raise ValueError(
137
+ f"invalid 'clip' value {clip!r}; "
138
+ "must be 'on', 'off', 'inherit', or a boolean"
139
+ )
140
+ # "off" -> None is correct from the map
141
+ return _CLIP_MAP.get(clip.lower(), None)
142
+ raise ValueError(
143
+ f"invalid 'clip' value {clip!r}; "
144
+ "must be 'on', 'off', 'inherit', or a boolean"
145
+ )
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Mask value normalisation
150
+ # ---------------------------------------------------------------------------
151
+
152
+ _MASK_MAP = {
153
+ "inherit": True,
154
+ "none": False,
155
+ }
156
+
157
+
158
+ def _valid_mask(mask: Any) -> Any:
159
+ """Normalise *mask* to its internal representation.
160
+
161
+ Parameters
162
+ ----------
163
+ mask : bool, str, or other
164
+ ``"inherit"`` -> ``True``, ``"none"`` -> ``False``.
165
+ Booleans pass through unchanged. Other objects (e.g. mask grobs)
166
+ are returned as-is.
167
+
168
+ Returns
169
+ -------
170
+ bool or object
171
+
172
+ Raises
173
+ ------
174
+ ValueError
175
+ If *mask* is a string that is not one of the accepted values.
176
+ """
177
+ if isinstance(mask, bool):
178
+ return mask
179
+ if isinstance(mask, str):
180
+ val = _MASK_MAP.get(mask.lower())
181
+ if val is None:
182
+ raise ValueError(
183
+ f"invalid 'mask' value {mask!r}; "
184
+ "must be 'inherit', 'none', or a boolean"
185
+ )
186
+ return val
187
+ # Arbitrary mask objects (grobs, etc.) pass through
188
+ return mask
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # Viewport class
193
+ # ---------------------------------------------------------------------------
194
+
195
+
196
+ class Viewport:
197
+ """A viewport specification -- a rectangular sub-region of a device.
198
+
199
+ Parameters
200
+ ----------
201
+ x : Unit or float or None
202
+ Horizontal position. Defaults to ``Unit(0.5, "npc")``.
203
+ y : Unit or float or None
204
+ Vertical position. Defaults to ``Unit(0.5, "npc")``.
205
+ width : Unit or float or None
206
+ Width. Defaults to ``Unit(1, "npc")``.
207
+ height : Unit or float or None
208
+ Height. Defaults to ``Unit(1, "npc")``.
209
+ default_units : str
210
+ Unit type applied when *x*, *y*, *width*, or *height* are given
211
+ as plain numbers rather than :class:`Unit` objects.
212
+ just : str or sequence
213
+ Justification specification (see :func:`valid_just`).
214
+ gp : Gpar or None
215
+ Graphical parameter settings.
216
+ clip : str or bool
217
+ Clipping mode: ``"inherit"`` (default), ``"on"``, or ``"off"``.
218
+ mask : bool or str
219
+ Masking mode: ``"inherit"`` (default, mapped to ``True``),
220
+ ``"none"`` (mapped to ``False``), or a mask grob.
221
+ xscale : sequence of float or None
222
+ Two-element ``[min, max]`` giving the native x-coordinate range.
223
+ Defaults to ``[0, 1]``.
224
+ yscale : sequence of float or None
225
+ Two-element ``[min, max]`` giving the native y-coordinate range.
226
+ Defaults to ``[0, 1]``.
227
+ angle : float
228
+ Rotation angle in degrees.
229
+ layout : GridLayout or None
230
+ Layout for arranging children of this viewport.
231
+ layout_pos_row : int, sequence of int, or None
232
+ Row position(s) of this viewport in the parent's layout.
233
+ layout_pos_col : int, sequence of int, or None
234
+ Column position(s) of this viewport in the parent's layout.
235
+ name : str or None
236
+ Viewport name. Auto-generated (``"GRID.VP.<n>"``) if ``None``.
237
+
238
+ Raises
239
+ ------
240
+ ValueError
241
+ If any argument fails validation.
242
+ TypeError
243
+ If *gp* is not a :class:`Gpar` (or ``None``).
244
+
245
+ Examples
246
+ --------
247
+ >>> vp = Viewport(width=Unit(0.8, "npc"), height=Unit(0.8, "npc"))
248
+ >>> str(vp)
249
+ 'viewport[GRID.VP.1]'
250
+ """
251
+
252
+ # We store everything on the instance rather than using ``__slots__``
253
+ # because pushed-viewport copies add additional runtime attributes
254
+ # (``parentgpar``, ``trans``, ``children``, etc.) and slots would
255
+ # prevent that.
256
+
257
+ def __init__(
258
+ self,
259
+ x: Union[Unit, float, int, None] = None,
260
+ y: Union[Unit, float, int, None] = None,
261
+ width: Union[Unit, float, int, None] = None,
262
+ height: Union[Unit, float, int, None] = None,
263
+ default_units: str = "npc",
264
+ just: Any = "centre",
265
+ gp: Optional[Gpar] = None,
266
+ clip: Any = "inherit",
267
+ mask: Any = "inherit",
268
+ xscale: Optional[Sequence[float]] = None,
269
+ yscale: Optional[Sequence[float]] = None,
270
+ angle: float = 0,
271
+ layout: Optional[GridLayout] = None,
272
+ layout_pos_row: Optional[Union[int, Sequence[int]]] = None,
273
+ layout_pos_col: Optional[Union[int, Sequence[int]]] = None,
274
+ name: Optional[str] = None,
275
+ ) -> None:
276
+ # -- position / size defaults ----------------------------------------
277
+ if x is None:
278
+ x = Unit(0.5, "npc")
279
+ if y is None:
280
+ y = Unit(0.5, "npc")
281
+ if width is None:
282
+ width = Unit(1, "npc")
283
+ if height is None:
284
+ height = Unit(1, "npc")
285
+
286
+ # Coerce plain numerics to Unit with *default_units*
287
+ if not is_unit(x):
288
+ x = Unit(x, default_units)
289
+ if not is_unit(y):
290
+ y = Unit(y, default_units)
291
+ if not is_unit(width):
292
+ width = Unit(width, default_units)
293
+ if not is_unit(height):
294
+ height = Unit(height, default_units)
295
+
296
+ # -- validate scalar unit length -------------------------------------
297
+ for arg_name, arg_val in [
298
+ ("x", x), ("y", y), ("width", width), ("height", height),
299
+ ]:
300
+ if len(arg_val) != 1:
301
+ raise ValueError(
302
+ f"'{arg_name}' must be a unit of length 1, "
303
+ f"got length {len(arg_val)}"
304
+ )
305
+
306
+ # -- gp ---------------------------------------------------------------
307
+ if gp is None:
308
+ gp = Gpar()
309
+ if not isinstance(gp, Gpar):
310
+ raise TypeError(
311
+ f"invalid 'gp' value: expected Gpar, got {type(gp).__name__}"
312
+ )
313
+
314
+ # -- clip / mask ------------------------------------------------------
315
+ clip = _valid_clip(clip)
316
+ mask = _valid_mask(mask)
317
+
318
+ # -- scales -----------------------------------------------------------
319
+ if xscale is None:
320
+ xscale = [0.0, 1.0]
321
+ if yscale is None:
322
+ yscale = [0.0, 1.0]
323
+
324
+ xscale = [float(v) for v in xscale]
325
+ yscale = [float(v) for v in yscale]
326
+
327
+ if len(xscale) != 2 or not all(math.isfinite(v) for v in xscale):
328
+ raise ValueError("invalid 'xscale' in viewport")
329
+ if xscale[1] == xscale[0]:
330
+ raise ValueError(
331
+ "invalid 'xscale' in viewport: range must be non-zero"
332
+ )
333
+
334
+ if len(yscale) != 2 or not all(math.isfinite(v) for v in yscale):
335
+ raise ValueError("invalid 'yscale' in viewport")
336
+ if yscale[1] == yscale[0]:
337
+ raise ValueError(
338
+ "invalid 'yscale' in viewport: range must be non-zero"
339
+ )
340
+
341
+ # -- angle ------------------------------------------------------------
342
+ angle = float(angle)
343
+ if not math.isfinite(angle):
344
+ raise ValueError("invalid 'angle' in viewport")
345
+
346
+ # -- layout -----------------------------------------------------------
347
+ if layout is not None and not isinstance(layout, GridLayout):
348
+ raise ValueError("invalid 'layout' in viewport")
349
+
350
+ # -- layout position ---------------------------------------------------
351
+ if layout_pos_row is not None:
352
+ if isinstance(layout_pos_row, (int, np.integer)):
353
+ layout_pos_row = [int(layout_pos_row), int(layout_pos_row)]
354
+ else:
355
+ vals = [int(v) for v in layout_pos_row]
356
+ layout_pos_row = [min(vals), max(vals)]
357
+ if not all(math.isfinite(v) for v in layout_pos_row):
358
+ raise ValueError("invalid 'layout_pos_row' in viewport")
359
+
360
+ if layout_pos_col is not None:
361
+ if isinstance(layout_pos_col, (int, np.integer)):
362
+ layout_pos_col = [int(layout_pos_col), int(layout_pos_col)]
363
+ else:
364
+ vals = [int(v) for v in layout_pos_col]
365
+ layout_pos_col = [min(vals), max(vals)]
366
+ if not all(math.isfinite(v) for v in layout_pos_col):
367
+ raise ValueError("invalid 'layout_pos_col' in viewport")
368
+
369
+ # -- justification ----------------------------------------------------
370
+ just_pair = valid_just(just)
371
+
372
+ # -- name --------------------------------------------------------------
373
+ if name is None:
374
+ name = _vp_auto_name()
375
+ if not isinstance(name, str) or not name:
376
+ raise ValueError(
377
+ f"invalid viewport name: {name!r}"
378
+ )
379
+
380
+ # -- store validated fields -------------------------------------------
381
+ self._x: Unit = x
382
+ self._y: Unit = y
383
+ self._width: Unit = width
384
+ self._height: Unit = height
385
+ self._default_units: str = default_units
386
+ self._just: Tuple[float, float] = just_pair
387
+ self._gp: Gpar = gp
388
+ self._clip: Any = clip
389
+ self._mask: Any = mask
390
+ self._xscale: List[float] = xscale
391
+ self._yscale: List[float] = yscale
392
+ self._angle: float = angle
393
+ self._layout: Optional[GridLayout] = layout
394
+ self._layout_pos_row: Optional[List[int]] = layout_pos_row
395
+ self._layout_pos_col: Optional[List[int]] = layout_pos_col
396
+ self._name: str = name
397
+
398
+ # -- pushed-viewport slots (filled in when the vp is pushed) ----------
399
+ self.parentgpar: Optional[Gpar] = None
400
+ self.gpar: Optional[Gpar] = None
401
+ self.trans: Optional[np.ndarray] = None
402
+ self.widths: Optional[Any] = None
403
+ self.heights: Optional[Any] = None
404
+ self.width_cm: Optional[float] = None
405
+ self.height_cm: Optional[float] = None
406
+ self.rotation: Optional[float] = None
407
+ self.cliprect: Optional[Any] = None
408
+ self.parent: Optional["Viewport"] = None
409
+ self.children: Optional[dict] = None
410
+ self.devwidth: Optional[float] = None
411
+ self.devheight: Optional[float] = None
412
+ self.clippath: Optional[Any] = None
413
+ self.resolvedmask: Optional[Any] = None
414
+
415
+ # -----------------------------------------------------------------------
416
+ # Properties
417
+ # -----------------------------------------------------------------------
418
+
419
+ @property
420
+ def x(self) -> Unit:
421
+ """Horizontal position of the viewport.
422
+
423
+ Returns
424
+ -------
425
+ Unit
426
+ """
427
+ return self._x
428
+
429
+ @property
430
+ def y(self) -> Unit:
431
+ """Vertical position of the viewport.
432
+
433
+ Returns
434
+ -------
435
+ Unit
436
+ """
437
+ return self._y
438
+
439
+ @property
440
+ def width(self) -> Unit:
441
+ """Width of the viewport.
442
+
443
+ Returns
444
+ -------
445
+ Unit
446
+ """
447
+ return self._width
448
+
449
+ @property
450
+ def height(self) -> Unit:
451
+ """Height of the viewport.
452
+
453
+ Returns
454
+ -------
455
+ Unit
456
+ """
457
+ return self._height
458
+
459
+ @property
460
+ def default_units(self) -> str:
461
+ """Default unit type for numeric position/size arguments.
462
+
463
+ Returns
464
+ -------
465
+ str
466
+ """
467
+ return self._default_units
468
+
469
+ @property
470
+ def just(self) -> Tuple[float, float]:
471
+ """Justification as a ``(hjust, vjust)`` pair.
472
+
473
+ Returns
474
+ -------
475
+ tuple of float
476
+ """
477
+ return self._just
478
+
479
+ @property
480
+ def gp(self) -> Gpar:
481
+ """Graphical parameters associated with this viewport.
482
+
483
+ Returns
484
+ -------
485
+ Gpar
486
+ """
487
+ return self._gp
488
+
489
+ @property
490
+ def clip(self) -> Any:
491
+ """Clipping mode.
492
+
493
+ Returns
494
+ -------
495
+ bool or None
496
+ ``True`` for ``"on"``, ``None`` for ``"off"``, ``False`` for
497
+ ``"inherit"``.
498
+ """
499
+ return self._clip
500
+
501
+ @property
502
+ def mask(self) -> Any:
503
+ """Masking mode or mask object.
504
+
505
+ Returns
506
+ -------
507
+ bool or object
508
+ """
509
+ return self._mask
510
+
511
+ @property
512
+ def xscale(self) -> List[float]:
513
+ """Native x-coordinate range ``[min, max]``.
514
+
515
+ Returns
516
+ -------
517
+ list of float
518
+ """
519
+ return list(self._xscale)
520
+
521
+ @property
522
+ def yscale(self) -> List[float]:
523
+ """Native y-coordinate range ``[min, max]``.
524
+
525
+ Returns
526
+ -------
527
+ list of float
528
+ """
529
+ return list(self._yscale)
530
+
531
+ @property
532
+ def angle(self) -> float:
533
+ """Rotation angle in degrees.
534
+
535
+ Returns
536
+ -------
537
+ float
538
+ """
539
+ return self._angle
540
+
541
+ @property
542
+ def layout(self) -> Optional[GridLayout]:
543
+ """Layout for child arrangement, or ``None``.
544
+
545
+ Returns
546
+ -------
547
+ GridLayout or None
548
+ """
549
+ return self._layout
550
+
551
+ @property
552
+ def layout_pos_row(self) -> Optional[List[int]]:
553
+ """Row position(s) in parent layout, or ``None``.
554
+
555
+ Returns
556
+ -------
557
+ list of int or None
558
+ """
559
+ return self._layout_pos_row
560
+
561
+ @property
562
+ def layout_pos_col(self) -> Optional[List[int]]:
563
+ """Column position(s) in parent layout, or ``None``.
564
+
565
+ Returns
566
+ -------
567
+ list of int or None
568
+ """
569
+ return self._layout_pos_col
570
+
571
+ @property
572
+ def name(self) -> str:
573
+ """Name of the viewport.
574
+
575
+ Returns
576
+ -------
577
+ str
578
+ """
579
+ return self._name
580
+
581
+ # -----------------------------------------------------------------------
582
+ # String representations
583
+ # -----------------------------------------------------------------------
584
+
585
+ def __str__(self) -> str:
586
+ """Return a short description (mirrors R's ``as.character.viewport``).
587
+
588
+ Returns
589
+ -------
590
+ str
591
+ """
592
+ return f"viewport[{self._name}]"
593
+
594
+ def __repr__(self) -> str:
595
+ """Return a detailed description for debugging.
596
+
597
+ Returns
598
+ -------
599
+ str
600
+ """
601
+ parts = [
602
+ f"name={self._name!r}",
603
+ f"x={self._x!r}",
604
+ f"y={self._y!r}",
605
+ f"width={self._width!r}",
606
+ f"height={self._height!r}",
607
+ f"just={self._just!r}",
608
+ f"xscale={self._xscale!r}",
609
+ f"yscale={self._yscale!r}",
610
+ f"angle={self._angle!r}",
611
+ ]
612
+ return f"Viewport({', '.join(parts)})"
613
+
614
+ # -----------------------------------------------------------------------
615
+ # Copying
616
+ # -----------------------------------------------------------------------
617
+
618
+ def _copy(self) -> "Viewport":
619
+ """Return a shallow copy of this viewport.
620
+
621
+ Returns
622
+ -------
623
+ Viewport
624
+ """
625
+ return copy.copy(self)
626
+
627
+
628
+ # ---------------------------------------------------------------------------
629
+ # Type guard
630
+ # ---------------------------------------------------------------------------
631
+
632
+
633
+ def is_viewport(obj: Any) -> bool:
634
+ """Return ``True`` if *obj* is a :class:`Viewport` (or subclass).
635
+
636
+ Parameters
637
+ ----------
638
+ obj : object
639
+ Object to test.
640
+
641
+ Returns
642
+ -------
643
+ bool
644
+ """
645
+ return isinstance(obj, Viewport)
646
+
647
+
648
+ def _viewport_or_path(obj: Any) -> bool:
649
+ """Return ``True`` if *obj* is a Viewport or a VpPath."""
650
+ return isinstance(obj, (Viewport, VpPath))
651
+
652
+
653
+ # ---------------------------------------------------------------------------
654
+ # VpList -- parallel push
655
+ # ---------------------------------------------------------------------------
656
+
657
+
658
+ class VpList:
659
+ """A list of viewports to be pushed in parallel.
660
+
661
+ When pushed, all viewports in the list are pushed as siblings of the
662
+ current viewport. For all but the last element the navigation returns
663
+ to the common parent before pushing the next viewport; the final
664
+ element's viewport becomes the current viewport after the push.
665
+
666
+ Parameters
667
+ ----------
668
+ *vps : Viewport or VpPath
669
+ One or more viewports (or viewport paths).
670
+
671
+ Raises
672
+ ------
673
+ TypeError
674
+ If any element is not a :class:`Viewport` or :class:`VpPath`.
675
+
676
+ Examples
677
+ --------
678
+ >>> vl = VpList(Viewport(name="a"), Viewport(name="b"))
679
+ >>> len(vl)
680
+ 2
681
+ """
682
+
683
+ def __init__(self, *vps: Union[Viewport, VpPath]) -> None:
684
+ for v in vps:
685
+ if not _viewport_or_path(v):
686
+ raise TypeError(
687
+ f"only viewports allowed in VpList, got {type(v).__name__}"
688
+ )
689
+ self._vps: Tuple[Union[Viewport, VpPath], ...] = tuple(vps)
690
+
691
+ # -- container protocol ---------------------------------------------------
692
+
693
+ def __len__(self) -> int:
694
+ return len(self._vps)
695
+
696
+ def __getitem__(self, index: int) -> Union[Viewport, VpPath]:
697
+ return self._vps[index]
698
+
699
+ def __iter__(self) -> Iterator[Union[Viewport, VpPath]]:
700
+ return iter(self._vps)
701
+
702
+ # -- string representation -----------------------------------------------
703
+
704
+ def __str__(self) -> str:
705
+ """Mirrors R's ``as.character.vpList``.
706
+
707
+ Returns
708
+ -------
709
+ str
710
+ """
711
+ inner = ", ".join(str(v) for v in self._vps)
712
+ return f"({inner})"
713
+
714
+ def __repr__(self) -> str:
715
+ inner = ", ".join(repr(v) for v in self._vps)
716
+ return f"VpList({inner})"
717
+
718
+
719
+ # ---------------------------------------------------------------------------
720
+ # VpStack -- sequential (nested) push
721
+ # ---------------------------------------------------------------------------
722
+
723
+
724
+ class VpStack:
725
+ """A stack of viewports to be pushed sequentially (nested).
726
+
727
+ Each viewport in the stack is pushed inside the preceding one, producing
728
+ a chain of nested viewports.
729
+
730
+ Parameters
731
+ ----------
732
+ *vps : Viewport or VpPath
733
+ One or more viewports (or viewport paths).
734
+
735
+ Raises
736
+ ------
737
+ TypeError
738
+ If any element is not a :class:`Viewport` or :class:`VpPath`.
739
+
740
+ Examples
741
+ --------
742
+ >>> vs = VpStack(Viewport(name="outer"), Viewport(name="inner"))
743
+ >>> str(vs)
744
+ 'viewport[outer]->viewport[inner]'
745
+ """
746
+
747
+ def __init__(self, *vps: Union[Viewport, VpPath]) -> None:
748
+ for v in vps:
749
+ if not _viewport_or_path(v):
750
+ raise TypeError(
751
+ f"only viewports allowed in VpStack, got {type(v).__name__}"
752
+ )
753
+ self._vps: Tuple[Union[Viewport, VpPath], ...] = tuple(vps)
754
+
755
+ # -- container protocol ---------------------------------------------------
756
+
757
+ def __len__(self) -> int:
758
+ return len(self._vps)
759
+
760
+ def __getitem__(self, index: int) -> Union[Viewport, VpPath]:
761
+ return self._vps[index]
762
+
763
+ def __iter__(self) -> Iterator[Union[Viewport, VpPath]]:
764
+ return iter(self._vps)
765
+
766
+ # -- string representation -----------------------------------------------
767
+
768
+ def __str__(self) -> str:
769
+ """Mirrors R's ``as.character.vpStack``.
770
+
771
+ Returns
772
+ -------
773
+ str
774
+ """
775
+ return "->".join(str(v) for v in self._vps)
776
+
777
+ def __repr__(self) -> str:
778
+ inner = ", ".join(repr(v) for v in self._vps)
779
+ return f"VpStack({inner})"
780
+
781
+
782
+ # ---------------------------------------------------------------------------
783
+ # VpTree -- parent + children (VpList)
784
+ # ---------------------------------------------------------------------------
785
+
786
+
787
+ class VpTree:
788
+ """A viewport tree consisting of a parent viewport and a list of children.
789
+
790
+ When pushed, the *parent* viewport is pushed first, then the *children*
791
+ (a :class:`VpList`) are pushed inside it.
792
+
793
+ Parameters
794
+ ----------
795
+ parent : Viewport
796
+ The parent viewport.
797
+ children : VpList
798
+ The children to push inside *parent*.
799
+
800
+ Raises
801
+ ------
802
+ TypeError
803
+ If *parent* is not a Viewport/VpPath or *children* is not a
804
+ :class:`VpList`.
805
+
806
+ Examples
807
+ --------
808
+ >>> tree = VpTree(Viewport(name="p"), VpList(Viewport(name="c1")))
809
+ >>> str(tree)
810
+ 'viewport[p]->(viewport[c1])'
811
+ """
812
+
813
+ def __init__(
814
+ self,
815
+ parent: Union[Viewport, VpPath],
816
+ children: VpList,
817
+ ) -> None:
818
+ if not _viewport_or_path(parent):
819
+ raise TypeError(
820
+ "'parent' must be a Viewport or VpPath in VpTree"
821
+ )
822
+ if not isinstance(children, VpList):
823
+ raise TypeError(
824
+ "'children' must be a VpList in VpTree"
825
+ )
826
+ self._parent = parent
827
+ self._children = children
828
+
829
+ @property
830
+ def parent(self) -> Union[Viewport, VpPath]:
831
+ """Parent viewport.
832
+
833
+ Returns
834
+ -------
835
+ Viewport or VpPath
836
+ """
837
+ return self._parent
838
+
839
+ @property
840
+ def children(self) -> VpList:
841
+ """Child viewport list.
842
+
843
+ Returns
844
+ -------
845
+ VpList
846
+ """
847
+ return self._children
848
+
849
+ # -- string representation -----------------------------------------------
850
+
851
+ def __str__(self) -> str:
852
+ """Mirrors R's ``as.character.vpTree``.
853
+
854
+ Returns
855
+ -------
856
+ str
857
+ """
858
+ return f"{self._parent}->{self._children}"
859
+
860
+ def __repr__(self) -> str:
861
+ return f"VpTree(parent={self._parent!r}, children={self._children!r})"
862
+
863
+
864
+ # ---------------------------------------------------------------------------
865
+ # depth() generic
866
+ # ---------------------------------------------------------------------------
867
+
868
+
869
+ def depth(x: Any) -> int:
870
+ """Return the depth (number of nesting levels) of a viewport object.
871
+
872
+ Parameters
873
+ ----------
874
+ x : Viewport, VpList, VpStack, VpTree, or VpPath
875
+ The viewport object whose depth to compute.
876
+
877
+ Returns
878
+ -------
879
+ int
880
+ Number of levels.
881
+
882
+ Raises
883
+ ------
884
+ TypeError
885
+ If *x* is not a recognised viewport type.
886
+
887
+ Notes
888
+ -----
889
+ This mirrors R's ``depth()`` generic.
890
+
891
+ * ``Viewport``: always 1.
892
+ * ``VpList``: depth of the *last* element (since pushing a list leaves
893
+ you wherever the last element leaves you).
894
+ * ``VpStack``: sum of the depths of all elements.
895
+ * ``VpTree``: depth of the parent plus depth of the last child.
896
+ * ``VpPath``: number of path components.
897
+ """
898
+ if isinstance(x, Viewport):
899
+ return 1
900
+ if isinstance(x, VpList):
901
+ if len(x) == 0:
902
+ return 0
903
+ return depth(x[len(x) - 1])
904
+ if isinstance(x, VpStack):
905
+ return sum(depth(v) for v in x)
906
+ if isinstance(x, VpTree):
907
+ if len(x.children) == 0:
908
+ return depth(x.parent)
909
+ return depth(x.parent) + depth(x.children[len(x.children) - 1])
910
+ if isinstance(x, VpPath):
911
+ return x.n
912
+ raise TypeError(
913
+ f"depth() does not support {type(x).__name__}"
914
+ )
915
+
916
+
917
+ # ---------------------------------------------------------------------------
918
+ # State-management helpers (late-import pattern)
919
+ # ---------------------------------------------------------------------------
920
+
921
+ def _get_state() -> Any:
922
+ """Lazily import the state manager to avoid circular imports.
923
+
924
+ Returns
925
+ -------
926
+ module
927
+ The ``grid_py._state`` module.
928
+ """
929
+ from ._state import get_state # noqa: WPS433 (late import to break circular dep)
930
+ return get_state()
931
+
932
+
933
+ # ---------------------------------------------------------------------------
934
+ # Gpar restoration helper for viewport navigation
935
+ # ---------------------------------------------------------------------------
936
+
937
+
938
+ def _restore_gpar_for_up(state: Any, n: int) -> None:
939
+ """Restore gpar when navigating up/popping *n* viewports.
940
+
941
+ Mirrors R's ``L_upviewport`` / ``L_unsetviewport``:
942
+ walk *n* parent links from the current viewport to find the
943
+ outermost viewport being left, then replace the global gpar
944
+ with that viewport's ``parentgpar`` (the gpar that was active
945
+ *before* that viewport was pushed).
946
+
947
+ If *n* is 0 (meaning "to root"), the actual depth is computed
948
+ first, matching R's ``popViewport(0)`` → ``n <- vpDepth()``.
949
+
950
+ Parameters
951
+ ----------
952
+ state : GridState
953
+ The current grid state singleton.
954
+ n : int
955
+ Number of levels to navigate up (0 = to root).
956
+ """
957
+ from ._state import _vp_parent, _vp_attr # noqa: WPS433
958
+
959
+ vp = state.current_viewport()
960
+ if vp is None:
961
+ return
962
+
963
+ # n == 0 means "all the way to root". Compute actual depth.
964
+ if n == 0:
965
+ actual_n = 0
966
+ walk = vp
967
+ while _vp_parent(walk) is not None:
968
+ actual_n += 1
969
+ walk = _vp_parent(walk)
970
+ n = actual_n
971
+
972
+ if n <= 0:
973
+ return # already at root
974
+
975
+ # Walk n-1 parents from current viewport.
976
+ # After the loop, *vp* is the outermost viewport being left.
977
+ # R's C code: for (i = 1; i < n; i++) gvp = parent;
978
+ for _ in range(n - 1):
979
+ parent = _vp_parent(vp)
980
+ if parent is None:
981
+ break
982
+ vp = parent
983
+
984
+ # R: C_setGPar(VECTOR_ELT(gvp, PVP_PARENTGPAR))
985
+ pgp = _vp_attr(vp, "parentgpar", None)
986
+ if pgp is not None:
987
+ state.replace_gpar(pgp)
988
+
989
+
990
+ # ---------------------------------------------------------------------------
991
+ # Renderer-stack synchronisation helper
992
+ # ---------------------------------------------------------------------------
993
+
994
+
995
+ def _rebuild_renderer_stack(state: Any, renderer: Any) -> None:
996
+ """Reset the renderer's coordinate stack and rebuild it from root to current vp.
997
+
998
+ Walks from ``state.current_viewport()`` up to the root to collect the
999
+ path, then pushes each viewport onto the renderer in order.
1000
+ """
1001
+ from ._state import _vp_parent # noqa: WPS433
1002
+
1003
+ # Collect viewports from current → root (excluding the root sentinel).
1004
+ path: list = []
1005
+ vp = state.current_viewport()
1006
+ while vp is not None:
1007
+ parent = _vp_parent(vp)
1008
+ if parent is None:
1009
+ break # vp is the root — don't include it
1010
+ path.append(vp)
1011
+ vp = parent
1012
+ path.reverse()
1013
+
1014
+ # Reset renderer to root, then re-push the path.
1015
+ renderer.pop_viewport_to_root()
1016
+ for vp in path:
1017
+ renderer.push_viewport(vp)
1018
+
1019
+
1020
+ # ---------------------------------------------------------------------------
1021
+ # Navigation functions
1022
+ # ---------------------------------------------------------------------------
1023
+
1024
+
1025
+ def _push_single_vp(vp: Viewport, state: Any, renderer: Any) -> None:
1026
+ """Push a single :class:`Viewport` onto the stack with full gpar handling.
1027
+
1028
+ Mirrors R's ``push.vp.viewport`` (grid.R:31-53):
1029
+
1030
+ 1. ``vp.parentgpar ← current gpar`` — snapshot before push.
1031
+ 2. Merge ``vp._gp`` into current gpar (``set.gpar`` semantics:
1032
+ cex/alpha/lex are multiplicatively cumulative).
1033
+ 3. ``vp.gpar ← merged gpar`` — snapshot after merge.
1034
+ 4. Replace the global gpar with the merged result.
1035
+ 5. Push the viewport onto the state tree and renderer stack.
1036
+ """
1037
+ from ._gpar import Gpar
1038
+
1039
+ # 1. Store parent gpar on the viewport (R: vp$parentgpar <- C_getGPar)
1040
+ current_gpar = state.get_gpar()
1041
+ vp.parentgpar = copy.copy(current_gpar)
1042
+
1043
+ # 2-3. Merge vp._gp into current gpar → vp.gpar (R: set.gpar(vp$gp))
1044
+ vp_gp = getattr(vp, "_gp", None)
1045
+ if vp_gp is not None and len(vp_gp) > 0:
1046
+ merged = vp_gp._merge(current_gpar)
1047
+ else:
1048
+ merged = copy.copy(current_gpar)
1049
+ vp.gpar = merged
1050
+
1051
+ # 4. Replace global gpar (R: grid.Call.graphics(C_setGPar, temp))
1052
+ state.replace_gpar(merged)
1053
+
1054
+ # 5. Push viewport onto state tree and renderer
1055
+ state.push_viewport(vp)
1056
+ if renderer is not None and hasattr(renderer, "push_viewport"):
1057
+ renderer.push_viewport(vp)
1058
+
1059
+
1060
+ def push_viewport(
1061
+ *args: Union[Viewport, VpList, VpStack, VpTree, VpPath],
1062
+ recording: bool = True,
1063
+ ) -> None:
1064
+ """Push one or more viewports onto the viewport stack.
1065
+
1066
+ Each argument is pushed in order. A :class:`Viewport` is pushed
1067
+ directly; container types (:class:`VpList`, :class:`VpStack`,
1068
+ :class:`VpTree`) are traversed according to their semantics.
1069
+
1070
+ Mirrors R's ``pushViewport`` (grid.R:96-104) including gpar
1071
+ save/merge/restore on each viewport push.
1072
+
1073
+ Parameters
1074
+ ----------
1075
+ *args : Viewport, VpList, VpStack, VpTree, or VpPath
1076
+ Viewports to push.
1077
+ recording : bool
1078
+ Whether to record the operation on the display list.
1079
+
1080
+ Raises
1081
+ ------
1082
+ ValueError
1083
+ If no viewports are provided.
1084
+ """
1085
+ if len(args) == 0:
1086
+ raise ValueError("must specify at least one viewport")
1087
+ state = _get_state()
1088
+ renderer = state.get_renderer()
1089
+ for vp in args:
1090
+ _push_vp(vp, state, renderer, recording)
1091
+
1092
+
1093
+ def _push_vp(
1094
+ vp: Any, state: Any, renderer: Any, recording: bool
1095
+ ) -> None:
1096
+ """Dispatch a single viewport-like object for pushing.
1097
+
1098
+ Mirrors R's ``push.vp`` S3 dispatch (grid.R:24-90).
1099
+ """
1100
+ if isinstance(vp, Viewport):
1101
+ _push_single_vp(vp, state, renderer)
1102
+ elif isinstance(vp, VpPath):
1103
+ # R: push.vp.vpPath → downViewport(vp, strict=TRUE)
1104
+ down_viewport(vp, strict=True, recording=recording)
1105
+ elif isinstance(vp, VpStack):
1106
+ # R: push.vp.vpStack → lapply(vp, push.vp)
1107
+ for child in vp:
1108
+ _push_vp(child, state, renderer, recording)
1109
+ elif isinstance(vp, VpList):
1110
+ # R: push.vp.vpList → push all but last + upViewport, then push last
1111
+ n = len(vp)
1112
+ for i, child in enumerate(vp):
1113
+ _push_vp(child, state, renderer, recording)
1114
+ if i < n - 1:
1115
+ up_viewport(depth(child), recording=recording)
1116
+ elif isinstance(vp, VpTree):
1117
+ # R: push.vp.vpTree → push parent, then push children (VpList)
1118
+ parent = vp.parent
1119
+ if not (isinstance(parent, Viewport) and parent.name == "ROOT"):
1120
+ _push_vp(parent, state, renderer, recording)
1121
+ _push_vp(vp.children, state, renderer, recording)
1122
+ else:
1123
+ # Fallback: treat as a plain viewport
1124
+ _push_single_vp(vp, state, renderer)
1125
+
1126
+
1127
+ def pop_viewport(n: int = 1, recording: bool = True) -> None:
1128
+ """Pop *n* viewports from the viewport stack.
1129
+
1130
+ Mirrors R's ``popViewport`` (grid.R:211-225) + ``L_unsetviewport``
1131
+ (grid.c:885-1014): removes viewports from the tree and restores
1132
+ the ``parentgpar`` stored on the outermost popped viewport.
1133
+
1134
+ Parameters
1135
+ ----------
1136
+ n : int
1137
+ Number of viewports to pop. If ``0``, pop all viewports down
1138
+ to the root.
1139
+ recording : bool
1140
+ Whether to record the operation on the display list.
1141
+
1142
+ Raises
1143
+ ------
1144
+ ValueError
1145
+ If *n* < 0.
1146
+ """
1147
+ if n < 0:
1148
+ raise ValueError("must pop at least one viewport")
1149
+ state = _get_state()
1150
+ renderer = state.get_renderer()
1151
+
1152
+ # Restore gpar: walk *n* parents to find the outermost popped vp,
1153
+ # then use its parentgpar. (R: C_setGPar(gvp$parentgpar))
1154
+ # Must read before state.pop_viewport removes the viewports.
1155
+ _restore_gpar_for_up(state, n)
1156
+
1157
+ state.pop_viewport(n)
1158
+ # Synchronise the renderer's coordinate transform
1159
+ if renderer is not None:
1160
+ if n == 0:
1161
+ if hasattr(renderer, "pop_viewport_to_root"):
1162
+ renderer.pop_viewport_to_root()
1163
+ elif hasattr(renderer, "pop_viewport"):
1164
+ for _ in range(n):
1165
+ renderer.pop_viewport()
1166
+
1167
+
1168
+ def up_viewport(n: int = 1, recording: bool = True) -> Optional[VpPath]:
1169
+ """Navigate up *n* levels in the viewport tree without removing them.
1170
+
1171
+ Parameters
1172
+ ----------
1173
+ n : int
1174
+ Number of levels to navigate up. If ``0``, navigate to the
1175
+ root viewport.
1176
+ recording : bool
1177
+ Whether to record the operation on the display list.
1178
+
1179
+ Returns
1180
+ -------
1181
+ VpPath or None
1182
+ The path segment that was navigated.
1183
+
1184
+ Raises
1185
+ ------
1186
+ ValueError
1187
+ If *n* < 0.
1188
+ """
1189
+ if n < 0:
1190
+ raise ValueError("must navigate up at least one viewport")
1191
+ state = _get_state()
1192
+
1193
+ # Capture the path segment being navigated (mirrors R grid.R:234-238).
1194
+ # R returns the tail of the current path corresponding to the n levels
1195
+ # being navigated away from.
1196
+ up_path: Optional[VpPath] = None
1197
+ path_str = state.current_vp_path() # e.g. "ROOT/A/B"
1198
+ if path_str:
1199
+ parts = path_str.split("/")
1200
+ # Remove "ROOT" prefix for VpPath (R doesn't include ROOT)
1201
+ vp_parts = [p for p in parts if p != "ROOT"]
1202
+ if n == 0:
1203
+ # Navigate to root: return entire path
1204
+ if vp_parts:
1205
+ up_path = VpPath("/".join(vp_parts))
1206
+ elif len(vp_parts) >= n:
1207
+ tail = "/".join(vp_parts[-n:])
1208
+ if tail:
1209
+ up_path = VpPath(tail)
1210
+
1211
+ # Restore gpar before navigating (must read current vp first).
1212
+ # R's L_upviewport: C_setGPar(gvp$parentgpar)
1213
+ _restore_gpar_for_up(state, n)
1214
+
1215
+ state.up_viewport(n)
1216
+ # Synchronise the renderer's coordinate transform
1217
+ renderer = state.get_renderer()
1218
+ if renderer is not None:
1219
+ if n == 0:
1220
+ if hasattr(renderer, "pop_viewport_to_root"):
1221
+ renderer.pop_viewport_to_root()
1222
+ elif hasattr(renderer, "pop_viewport"):
1223
+ for _ in range(n):
1224
+ renderer.pop_viewport()
1225
+ return up_path
1226
+
1227
+
1228
+ def down_viewport(
1229
+ name: Union[str, VpPath],
1230
+ strict: bool = False,
1231
+ recording: bool = True,
1232
+ ) -> int:
1233
+ """Navigate down to a named viewport that has already been pushed.
1234
+
1235
+ Parameters
1236
+ ----------
1237
+ name : str or VpPath
1238
+ Name or path of the viewport to navigate to.
1239
+ strict : bool
1240
+ If ``True``, require an exact path match.
1241
+ recording : bool
1242
+ Whether to record the operation on the display list.
1243
+
1244
+ Returns
1245
+ -------
1246
+ int
1247
+ The depth navigated.
1248
+ """
1249
+ if isinstance(name, str):
1250
+ name = VpPath(name)
1251
+ state = _get_state()
1252
+ depth = state.down_viewport(str(name), strict=strict)
1253
+ # Synchronise the renderer's coordinate transform: rebuild the stack
1254
+ # from root to the new current viewport.
1255
+ renderer = state.get_renderer()
1256
+ if renderer is not None and hasattr(renderer, "pop_viewport_to_root"):
1257
+ _rebuild_renderer_stack(state, renderer)
1258
+ # Restore gpar for the target viewport (R: grid.R:173-175).
1259
+ # R's downViewport.vpPath: grid.Call.graphics(C_setGPar, pvp$gpar)
1260
+ target_vp = state.current_viewport()
1261
+ target_gpar = getattr(target_vp, "gpar", None)
1262
+ if target_gpar is not None:
1263
+ state.replace_gpar(target_gpar)
1264
+ return depth
1265
+
1266
+
1267
+ def seek_viewport(name: str, recording: bool = True) -> int:
1268
+ """Navigate to a named viewport from anywhere in the tree.
1269
+
1270
+ This is equivalent to navigating up to the root and then searching
1271
+ downward.
1272
+
1273
+ Parameters
1274
+ ----------
1275
+ name : str
1276
+ Name of the viewport to find.
1277
+ recording : bool
1278
+ Whether to record the operation on the display list.
1279
+
1280
+ Returns
1281
+ -------
1282
+ int
1283
+ The depth navigated from the root.
1284
+ """
1285
+ up_viewport(0, recording=recording)
1286
+ return down_viewport(name, recording=recording)
1287
+
1288
+
1289
+ # ---------------------------------------------------------------------------
1290
+ # Query functions
1291
+ # ---------------------------------------------------------------------------
1292
+
1293
+
1294
+ def current_viewport() -> Viewport:
1295
+ """Return the current viewport.
1296
+
1297
+ Returns
1298
+ -------
1299
+ Viewport
1300
+ """
1301
+ state = _get_state()
1302
+ return state.current_viewport()
1303
+
1304
+
1305
+ def current_vp_path() -> Optional[VpPath]:
1306
+ """Return the full path from the root to the current viewport.
1307
+
1308
+ Returns
1309
+ -------
1310
+ VpPath or None
1311
+ ``None`` if the current viewport is the root.
1312
+ """
1313
+ state = _get_state()
1314
+ return state.current_vp_path()
1315
+
1316
+
1317
+ def current_vp_tree() -> Union[Viewport, VpTree]:
1318
+ """Return the full viewport tree starting from the root.
1319
+
1320
+ Returns
1321
+ -------
1322
+ Viewport or VpTree
1323
+ """
1324
+ state = _get_state()
1325
+ return state.current_vp_tree()
1326
+
1327
+
1328
+ def current_transform() -> np.ndarray:
1329
+ """Return the 3x3 transformation matrix of the current viewport.
1330
+
1331
+ The matrix maps normalised parent coordinates (NPC) to device
1332
+ coordinates, incorporating position, size, justification, and
1333
+ rotation.
1334
+
1335
+ Returns
1336
+ -------
1337
+ numpy.ndarray
1338
+ A 3x3 float array.
1339
+ """
1340
+ state = _get_state()
1341
+ return state.current_transform()
1342
+
1343
+
1344
+ def current_rotation() -> float:
1345
+ """Return the cumulative rotation angle (degrees) of the current viewport.
1346
+
1347
+ Returns
1348
+ -------
1349
+ float
1350
+ """
1351
+ state = _get_state()
1352
+ return state.current_rotation()
1353
+
1354
+
1355
+ def current_parent(n: int = 1) -> Optional[Viewport]:
1356
+ """Return the *n*-th generation ancestor of the current viewport.
1357
+
1358
+ Parameters
1359
+ ----------
1360
+ n : int
1361
+ Number of generations to go up (default 1 = immediate parent).
1362
+
1363
+ Returns
1364
+ -------
1365
+ Viewport or None
1366
+ ``None`` if the ancestor is the root (which has no parent).
1367
+
1368
+ Raises
1369
+ ------
1370
+ ValueError
1371
+ If *n* < 1 or exceeds the depth of the viewport stack.
1372
+ """
1373
+ if n < 1:
1374
+ raise ValueError("invalid number of generations")
1375
+ state = _get_state()
1376
+ return state.current_parent(n)
1377
+
1378
+
1379
+ # ---------------------------------------------------------------------------
1380
+ # Convenience viewport constructors
1381
+ # ---------------------------------------------------------------------------
1382
+
1383
+
1384
+ def data_viewport(
1385
+ xData: Optional[Sequence[float]] = None,
1386
+ yData: Optional[Sequence[float]] = None,
1387
+ xscale: Optional[Sequence[float]] = None,
1388
+ yscale: Optional[Sequence[float]] = None,
1389
+ extension: Union[float, Sequence[float]] = 0.05,
1390
+ **kwargs: Any,
1391
+ ) -> Viewport:
1392
+ """Create a viewport with scales derived from data ranges.
1393
+
1394
+ If *xscale* is not supplied it is computed from *xData* (and similarly
1395
+ for *yscale* / *yData*). An *extension* factor is applied to expand
1396
+ the range slightly beyond the data limits.
1397
+
1398
+ Parameters
1399
+ ----------
1400
+ xData : array-like or None
1401
+ Data for the x-axis.
1402
+ yData : array-like or None
1403
+ Data for the y-axis.
1404
+ xscale : sequence of float or None
1405
+ Explicit x-scale. Overrides *xData* if given.
1406
+ yscale : sequence of float or None
1407
+ Explicit y-scale. Overrides *yData* if given.
1408
+ extension : float
1409
+ Proportional extension of the data range on each side.
1410
+ **kwargs
1411
+ Additional keyword arguments passed to :class:`Viewport`.
1412
+
1413
+ Returns
1414
+ -------
1415
+ Viewport
1416
+
1417
+ Raises
1418
+ ------
1419
+ ValueError
1420
+ If neither *xData* nor *xscale* (or *yData* nor *yscale*) is
1421
+ supplied.
1422
+
1423
+ Notes
1424
+ -----
1425
+ Mirrors R's ``dataViewport()``.
1426
+ """
1427
+ # R: extension <- rep(extension, length.out = 2)
1428
+ if isinstance(extension, (list, tuple)):
1429
+ ext = [float(x) for x in extension]
1430
+ else:
1431
+ ext = [float(extension)]
1432
+ # Recycle to length 2 (R's rep(..., length.out=2))
1433
+ while len(ext) < 2:
1434
+ ext.append(ext[0])
1435
+ ext = ext[:2]
1436
+
1437
+ if xscale is None:
1438
+ if xData is None:
1439
+ raise ValueError(
1440
+ "must specify at least one of 'xData' or 'xscale'"
1441
+ )
1442
+ xarr = np.asarray(xData, dtype=float)
1443
+ rng = float(np.nanmax(xarr) - np.nanmin(xarr))
1444
+ xscale = [
1445
+ float(np.nanmin(xarr)) - ext[0] * rng,
1446
+ float(np.nanmax(xarr)) + ext[0] * rng,
1447
+ ]
1448
+
1449
+ if yscale is None:
1450
+ if yData is None:
1451
+ raise ValueError(
1452
+ "must specify at least one of 'yData' or 'yscale'"
1453
+ )
1454
+ yarr = np.asarray(yData, dtype=float)
1455
+ rng = float(np.nanmax(yarr) - np.nanmin(yarr))
1456
+ yscale = [
1457
+ float(np.nanmin(yarr)) - ext[1] * rng,
1458
+ float(np.nanmax(yarr)) + ext[1] * rng,
1459
+ ]
1460
+
1461
+ return Viewport(xscale=xscale, yscale=yscale, **kwargs)
1462
+
1463
+
1464
+ def plot_viewport(
1465
+ margins: Optional[Sequence[float]] = None,
1466
+ **kwargs: Any,
1467
+ ) -> Viewport:
1468
+ """Create a viewport with margins specified in lines.
1469
+
1470
+ This mirrors R's ``plotViewport()``. The four margins are given in the
1471
+ order ``[bottom, left, top, right]``.
1472
+
1473
+ Parameters
1474
+ ----------
1475
+ margins : sequence of float or None
1476
+ Four margin sizes in ``"lines"`` units, ordered
1477
+ ``[bottom, left, top, right]``. Defaults to
1478
+ ``[5.1, 4.1, 4.1, 2.1]``.
1479
+ **kwargs
1480
+ Additional keyword arguments passed to :class:`Viewport`.
1481
+
1482
+ Returns
1483
+ -------
1484
+ Viewport
1485
+ """
1486
+ if margins is None:
1487
+ margins = [5.1, 4.1, 4.1, 2.1]
1488
+ else:
1489
+ margins = list(margins)
1490
+ # Ensure exactly 4 values by recycling
1491
+ while len(margins) < 4:
1492
+ margins = margins * 2
1493
+ margins = [float(m) for m in margins[:4]]
1494
+
1495
+ bottom, left, top, right = margins
1496
+
1497
+ x = Unit(left, "lines")
1498
+ width = Unit(1, "npc") - Unit(left + right, "lines")
1499
+ y = Unit(bottom, "lines")
1500
+ height = Unit(1, "npc") - Unit(bottom + top, "lines")
1501
+
1502
+ return Viewport(
1503
+ x=x,
1504
+ width=width,
1505
+ y=y,
1506
+ height=height,
1507
+ just=["left", "bottom"],
1508
+ **kwargs,
1509
+ )
1510
+
1511
+
1512
+ # ---------------------------------------------------------------------------
1513
+ # edit_viewport
1514
+ # ---------------------------------------------------------------------------
1515
+
1516
+
1517
+ def edit_viewport(
1518
+ vp: Optional[Viewport] = None,
1519
+ **kwargs: Any,
1520
+ ) -> Viewport:
1521
+ """Return an edited copy of a viewport.
1522
+
1523
+ Creates a new :class:`Viewport` by taking the fields of *vp* and
1524
+ overriding any that are supplied via keyword arguments.
1525
+
1526
+ Parameters
1527
+ ----------
1528
+ vp : Viewport or None
1529
+ The viewport to edit. If ``None``, uses :func:`current_viewport`.
1530
+ **kwargs
1531
+ Fields to override (same names as :class:`Viewport` constructor
1532
+ parameters).
1533
+
1534
+ Returns
1535
+ -------
1536
+ Viewport
1537
+ A new viewport with the edited fields.
1538
+
1539
+ Notes
1540
+ -----
1541
+ Mirrors R's ``editViewport()``.
1542
+ """
1543
+ if vp is None:
1544
+ vp = current_viewport()
1545
+
1546
+ base_kwargs = {
1547
+ "x": vp.x,
1548
+ "y": vp.y,
1549
+ "width": vp.width,
1550
+ "height": vp.height,
1551
+ "default_units": vp.default_units,
1552
+ "just": vp.just,
1553
+ "gp": vp.gp,
1554
+ "clip": vp.clip,
1555
+ "mask": vp.mask,
1556
+ "xscale": vp.xscale,
1557
+ "yscale": vp.yscale,
1558
+ "angle": vp.angle,
1559
+ "layout": vp.layout,
1560
+ "layout_pos_row": vp.layout_pos_row,
1561
+ "layout_pos_col": vp.layout_pos_col,
1562
+ "name": vp.name,
1563
+ }
1564
+ # Remap clip from internal representation back to constructor-friendly form
1565
+ if "clip" not in kwargs:
1566
+ clip_val = base_kwargs["clip"]
1567
+ if clip_val is True:
1568
+ base_kwargs["clip"] = "on"
1569
+ elif clip_val is None:
1570
+ base_kwargs["clip"] = "off"
1571
+ elif clip_val is False:
1572
+ base_kwargs["clip"] = "inherit"
1573
+ # Similarly for mask
1574
+ if "mask" not in kwargs:
1575
+ mask_val = base_kwargs["mask"]
1576
+ if mask_val is True:
1577
+ base_kwargs["mask"] = "inherit"
1578
+ elif mask_val is False:
1579
+ base_kwargs["mask"] = "none"
1580
+
1581
+ base_kwargs.update(kwargs)
1582
+ return Viewport(**base_kwargs)
1583
+
1584
+
1585
+ # ---------------------------------------------------------------------------
1586
+ # show_viewport
1587
+ # ---------------------------------------------------------------------------
1588
+
1589
+
1590
+ def show_viewport(
1591
+ vp: Optional[Viewport] = None,
1592
+ recurse: bool = True,
1593
+ depth_val: int = 0,
1594
+ **kwargs: Any,
1595
+ ) -> str:
1596
+ """Return a human-readable summary of a viewport (tree).
1597
+
1598
+ Parameters
1599
+ ----------
1600
+ vp : Viewport or None
1601
+ The viewport to display. If ``None``, uses
1602
+ :func:`current_viewport`.
1603
+ recurse : bool
1604
+ Whether to recurse into children.
1605
+ depth_val : int
1606
+ Current indentation depth (used internally for recursive calls).
1607
+ **kwargs
1608
+ Reserved for future use.
1609
+
1610
+ Returns
1611
+ -------
1612
+ str
1613
+ Multi-line summary string.
1614
+
1615
+ Notes
1616
+ -----
1617
+ Mirrors R's ``showViewport()`` output.
1618
+ """
1619
+ if vp is None:
1620
+ vp = current_viewport()
1621
+
1622
+ indent = " " * depth_val
1623
+ lines: List[str] = []
1624
+ lines.append(f"{indent}{vp}")
1625
+ lines.append(f"{indent} x = {vp.x!r}")
1626
+ lines.append(f"{indent} y = {vp.y!r}")
1627
+ lines.append(f"{indent} width = {vp.width!r}")
1628
+ lines.append(f"{indent} height = {vp.height!r}")
1629
+ lines.append(f"{indent} just = {vp.just!r}")
1630
+ lines.append(f"{indent} xscale = {vp.xscale!r}")
1631
+ lines.append(f"{indent} yscale = {vp.yscale!r}")
1632
+ lines.append(f"{indent} angle = {vp.angle!r}")
1633
+
1634
+ if vp.layout is not None:
1635
+ lines.append(f"{indent} layout = {vp.layout!r}")
1636
+ if vp.layout_pos_row is not None:
1637
+ lines.append(f"{indent} layout.pos.row = {vp.layout_pos_row!r}")
1638
+ if vp.layout_pos_col is not None:
1639
+ lines.append(f"{indent} layout.pos.col = {vp.layout_pos_col!r}")
1640
+
1641
+ if recurse and vp.children:
1642
+ for child_name, child_vp in vp.children.items():
1643
+ lines.append(
1644
+ show_viewport(
1645
+ child_vp, recurse=True, depth_val=depth_val + 1
1646
+ )
1647
+ )
1648
+
1649
+ return "\n".join(lines)