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/_highlevel.py ADDED
@@ -0,0 +1,2176 @@
1
+ """High-level grid functions -- Python port of R's grid high-level API.
2
+
3
+ This module ports functionality from three R source files:
4
+
5
+ * ``grid/R/highlevel.R`` -- grid.grill, grid.show.layout, grid.show.viewport,
6
+ grid.plot.and.legend, grid.abline, layoutTorture, grid.multipanel, etc.
7
+ * ``grid/R/frames.R`` -- frameGrob, grid.frame, packGrob, grid.pack,
8
+ placeGrob, grid.place, and internal helpers.
9
+ * ``grid/R/components.R`` -- xaxisGrob, grid.xaxis, yaxisGrob, grid.yaxis,
10
+ legendGrob, grid.legend, and related helpers.
11
+
12
+ References
13
+ ----------
14
+ R source: ``src/library/grid/R/highlevel.R``, ``src/library/grid/R/frames.R``,
15
+ ``src/library/grid/R/components.R``
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import copy
21
+ import math
22
+ from typing import (
23
+ Any,
24
+ Dict,
25
+ List,
26
+ Optional,
27
+ Sequence,
28
+ Tuple,
29
+ Union,
30
+ )
31
+
32
+ import numpy as np
33
+
34
+ from ._gpar import Gpar
35
+ from ._grob import (
36
+ GEdit,
37
+ GEditList,
38
+ GList,
39
+ GTree,
40
+ Grob,
41
+ add_grob,
42
+ apply_edits,
43
+ edit_grob,
44
+ get_grob,
45
+ grob_tree,
46
+ is_grob,
47
+ remove_grob,
48
+ set_grob,
49
+ )
50
+ from ._just import valid_just
51
+ from ._layout import (
52
+ GridLayout,
53
+ layout_heights,
54
+ layout_ncol,
55
+ layout_nrow,
56
+ layout_widths,
57
+ )
58
+ from ._primitives import (
59
+ grid_lines,
60
+ grid_points,
61
+ grid_rect,
62
+ grid_segments,
63
+ grid_text,
64
+ lines_grob,
65
+ null_grob,
66
+ points_grob,
67
+ rect_grob,
68
+ segments_grob,
69
+ text_grob,
70
+ )
71
+ from ._units import Unit, is_unit, unit_c, unit_pmax
72
+ from ._viewport import (
73
+ Viewport,
74
+ VpStack,
75
+ current_viewport,
76
+ pop_viewport,
77
+ push_viewport,
78
+ )
79
+ from ._draw import grid_draw, grid_newpage, grid_pretty
80
+
81
+ __all__ = [
82
+ # high-level (highlevel.R)
83
+ "grid_grill",
84
+ "grid_plot_and_legend",
85
+ "grid_show_layout",
86
+ "grid_show_viewport",
87
+ "grid_abline",
88
+ "layout_torture",
89
+ # frames (frames.R)
90
+ "frame_grob",
91
+ "grid_frame",
92
+ "pack_grob",
93
+ "grid_pack",
94
+ "place_grob",
95
+ "grid_place",
96
+ # components (components.R)
97
+ "xaxis_grob",
98
+ "yaxis_grob",
99
+ "grid_xaxis",
100
+ "grid_yaxis",
101
+ "legend_grob",
102
+ "grid_legend",
103
+ "grid_multipanel",
104
+ "grid_panel",
105
+ "grid_strip",
106
+ "grid_top_level_vp",
107
+ ]
108
+
109
+
110
+ # =========================================================================
111
+ # Internal helpers
112
+ # =========================================================================
113
+
114
+
115
+ def _ensure_unit(value: Any, default_units: str = "npc") -> Unit:
116
+ """Coerce *value* to a :class:`Unit` if it is not one already."""
117
+ if is_unit(value):
118
+ return value
119
+ return Unit(value, default_units)
120
+
121
+
122
+ def _is_even(n: int) -> bool:
123
+ """Return ``True`` if *n* is even."""
124
+ return n % 2 == 0
125
+
126
+
127
+ def _is_odd(n: int) -> bool:
128
+ """Return ``True`` if *n* is odd."""
129
+ return n % 2 == 1
130
+
131
+
132
+ def _extend_range(x: Sequence[float], f: float = 0.05) -> Tuple[float, float]:
133
+ """Extend range of *x* by fraction *f* on each side (like R extendrange)."""
134
+ mn, mx = float(min(x)), float(max(x))
135
+ rng = mx - mn
136
+ if rng == 0:
137
+ rng = 1.0
138
+ return (mn - f * rng, mx + f * rng)
139
+
140
+
141
+ # =========================================================================
142
+ # Frame / Pack / Place (frames.R)
143
+ # =========================================================================
144
+
145
+
146
+ def frame_grob(
147
+ layout: Optional[GridLayout] = None,
148
+ name: Optional[str] = None,
149
+ gp: Optional[Gpar] = None,
150
+ vp: Optional[Any] = None,
151
+ ) -> GTree:
152
+ """Create a frame grob -- a GTree intended for packing child grobs.
153
+
154
+ Parameters
155
+ ----------
156
+ layout : GridLayout or None
157
+ Optional initial layout for the frame.
158
+ name : str or None
159
+ Grob name (auto-generated if ``None``).
160
+ gp : Gpar or None
161
+ Graphical parameters.
162
+ vp : object or None
163
+ Viewport.
164
+
165
+ Returns
166
+ -------
167
+ GTree
168
+ A GTree with ``_grid_class="frame"`` and a *framevp* attribute.
169
+ """
170
+ framevp: Optional[Viewport] = None
171
+ if layout is not None:
172
+ framevp = Viewport(layout=layout)
173
+ return GTree(
174
+ name=name,
175
+ gp=gp,
176
+ vp=vp,
177
+ _grid_class="frame",
178
+ framevp=framevp,
179
+ )
180
+
181
+
182
+ def grid_frame(
183
+ layout: Optional[GridLayout] = None,
184
+ name: Optional[str] = None,
185
+ gp: Optional[Gpar] = None,
186
+ vp: Optional[Any] = None,
187
+ draw: bool = True,
188
+ ) -> GTree:
189
+ """Create (and optionally draw) a frame grob.
190
+
191
+ Parameters
192
+ ----------
193
+ layout : GridLayout or None
194
+ Optional initial layout.
195
+ name : str or None
196
+ Grob name.
197
+ gp : Gpar or None
198
+ Graphical parameters.
199
+ vp : object or None
200
+ Viewport.
201
+ draw : bool
202
+ If ``True``, draw the frame immediately.
203
+
204
+ Returns
205
+ -------
206
+ GTree
207
+ The frame grob.
208
+ """
209
+ fg = frame_grob(layout=layout, name=name, gp=gp, vp=vp)
210
+ if draw:
211
+ grid_draw(fg)
212
+ return fg
213
+
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # Internal helpers for cell grobs and packing
217
+ # ---------------------------------------------------------------------------
218
+
219
+
220
+ def _cell_viewport(
221
+ col: Any,
222
+ row: Any,
223
+ border: Optional[Sequence[Unit]],
224
+ ) -> Any:
225
+ """Build a viewport (or VpStack) for a cell, optionally with border insets.
226
+
227
+ Parameters
228
+ ----------
229
+ col : int or list
230
+ Column index or range.
231
+ row : int or list
232
+ Row index or range.
233
+ border : list of Unit or None
234
+ Four-element border ``[bottom, left, top, right]``.
235
+
236
+ Returns
237
+ -------
238
+ Viewport or VpStack
239
+ """
240
+ vp = Viewport(layout_pos_col=col, layout_pos_row=row)
241
+ if border is not None:
242
+ inner = Viewport(
243
+ x=border[1],
244
+ y=border[0],
245
+ width=Unit(1, "npc") - (border[1] + border[3]),
246
+ height=Unit(1, "npc") - (border[0] + border[2]),
247
+ just=["left", "bottom"],
248
+ )
249
+ return VpStack(vp, inner)
250
+ return vp
251
+
252
+
253
+ def _cell_grob(
254
+ col: Any,
255
+ row: Any,
256
+ border: Optional[Sequence[Unit]],
257
+ grob: Grob,
258
+ dynamic: bool,
259
+ vp: Any,
260
+ ) -> GTree:
261
+ """Wrap a grob in a cellGrob container.
262
+
263
+ Parameters
264
+ ----------
265
+ col : int or list
266
+ Column position.
267
+ row : int or list
268
+ Row position.
269
+ border : list of Unit or None
270
+ Border insets.
271
+ grob : Grob
272
+ The child grob.
273
+ dynamic : bool
274
+ Whether to use dynamic sizing.
275
+ vp : object
276
+ Cell viewport.
277
+
278
+ Returns
279
+ -------
280
+ GTree
281
+ A GTree with ``_grid_class="cellGrob"``.
282
+ """
283
+ return GTree(
284
+ children=GList(grob),
285
+ _grid_class="cellGrob",
286
+ col=col,
287
+ row=row,
288
+ border=border,
289
+ dynamic=dynamic,
290
+ cellvp=vp,
291
+ )
292
+
293
+
294
+ def _frame_dim(frame: GTree) -> Tuple[int, int]:
295
+ """Return ``(nrow, ncol)`` of *frame*'s layout, or ``(0, 0)``."""
296
+ framevp = getattr(frame, "framevp", None)
297
+ if framevp is None:
298
+ return (0, 0)
299
+ lay = getattr(framevp, "layout", None)
300
+ if lay is None:
301
+ return (0, 0)
302
+ return (layout_nrow(lay), layout_ncol(lay))
303
+
304
+
305
+ # -- column / row specification helpers ------------------------------------
306
+
307
+
308
+ def _num_col_specs(
309
+ side: Optional[str],
310
+ col: Optional[Any],
311
+ col_before: Optional[int],
312
+ col_after: Optional[int],
313
+ ) -> int:
314
+ side_counts = 0 if (side is None or side in ("top", "bottom")) else 1
315
+ return side_counts + (col is not None) + (col_before is not None) + (col_after is not None)
316
+
317
+
318
+ def _col_spec(
319
+ side: Optional[str],
320
+ col: Optional[int],
321
+ col_before: Optional[int],
322
+ col_after: Optional[int],
323
+ ncol: int,
324
+ ) -> int:
325
+ if side is not None:
326
+ if side == "left":
327
+ return 1
328
+ if side == "right":
329
+ return ncol + 1
330
+ if col_before is not None:
331
+ return col_before
332
+ if col_after is not None:
333
+ return col_after + 1
334
+ return col # type: ignore[return-value]
335
+
336
+
337
+ def _new_col(
338
+ side: Optional[str],
339
+ col: Optional[Any],
340
+ col_before: Optional[int],
341
+ col_after: Optional[int],
342
+ ncol: int,
343
+ ) -> bool:
344
+ result = True
345
+ if col is not None:
346
+ if isinstance(col, (list, tuple)) and len(col) == 2:
347
+ if col[0] < 1 or col[1] > ncol:
348
+ raise ValueError("'col' can only be a range of existing columns")
349
+ result = False
350
+ else:
351
+ c = col if isinstance(col, int) else col
352
+ if c < 1 or c > ncol + 1:
353
+ raise ValueError("invalid 'col' specification")
354
+ result = c == ncol + 1
355
+ return result
356
+
357
+
358
+ def _num_row_specs(
359
+ side: Optional[str],
360
+ row: Optional[Any],
361
+ row_before: Optional[int],
362
+ row_after: Optional[int],
363
+ ) -> int:
364
+ side_counts = 0 if (side is None or side in ("left", "right")) else 1
365
+ return side_counts + (row is not None) + (row_before is not None) + (row_after is not None)
366
+
367
+
368
+ def _row_spec(
369
+ side: Optional[str],
370
+ row: Optional[int],
371
+ row_before: Optional[int],
372
+ row_after: Optional[int],
373
+ nrow: int,
374
+ ) -> int:
375
+ if side is not None:
376
+ if side == "top":
377
+ return 1
378
+ if side == "bottom":
379
+ return nrow + 1
380
+ if row_before is not None:
381
+ return row_before
382
+ if row_after is not None:
383
+ return row_after + 1
384
+ return row # type: ignore[return-value]
385
+
386
+
387
+ def _new_row(
388
+ side: Optional[str],
389
+ row: Optional[Any],
390
+ row_before: Optional[int],
391
+ row_after: Optional[int],
392
+ nrow: int,
393
+ ) -> bool:
394
+ result = True
395
+ if row is not None:
396
+ if isinstance(row, (list, tuple)) and len(row) == 2:
397
+ if row[0] < 1 or row[1] > nrow:
398
+ raise ValueError("'row' can only be a range of existing rows")
399
+ result = False
400
+ else:
401
+ r = row if isinstance(row, int) else row
402
+ if r < 1 or r > nrow + 1:
403
+ raise ValueError("invalid 'row' specification")
404
+ result = r == nrow + 1
405
+ return result
406
+
407
+
408
+ def _mod_dims(
409
+ dim: Unit,
410
+ dims: Unit,
411
+ index: int,
412
+ is_new: bool,
413
+ nindex: int,
414
+ force: bool,
415
+ ) -> Unit:
416
+ """Update dimension list when packing a new grob.
417
+
418
+ Parameters
419
+ ----------
420
+ dim : Unit
421
+ Width or height of the new grob.
422
+ dims : Unit
423
+ Current list of widths or heights.
424
+ index : int
425
+ 1-based index for the new grob's row/column.
426
+ is_new : bool
427
+ Whether a new row/column is being added.
428
+ nindex : int
429
+ Current number of rows/columns (before adding).
430
+ force : bool
431
+ If ``True``, override existing dimension; otherwise take the max.
432
+
433
+ Returns
434
+ -------
435
+ Unit
436
+ """
437
+ if is_new:
438
+ if index == 1:
439
+ return unit_c(dim, dims)
440
+ elif index == nindex:
441
+ return unit_c(dims, dim)
442
+ else:
443
+ # Insert before the existing index
444
+ before = dims[0 : index - 1] if index > 1 else None
445
+ after = dims[index - 1 :] if index <= nindex else None
446
+ parts: list[Unit] = []
447
+ if before is not None:
448
+ parts.append(before)
449
+ parts.append(dim)
450
+ if after is not None:
451
+ parts.append(after)
452
+ result = parts[0]
453
+ for p in parts[1:]:
454
+ result = unit_c(result, p)
455
+ return result
456
+ else:
457
+ # Existing row/col: take max or force
458
+ # R frames.R:254-255: if (!force) dim <- max(dim, dims[index])
459
+ if not force:
460
+ existing = dims[index - 1 : index] # 1-based → 0-based slice
461
+ dim = unit_pmax(dim, existing)
462
+ # Replace the dimension at *index* (1-based)
463
+ idx0 = index - 1
464
+ parts_list: list[Unit] = []
465
+ if idx0 > 0:
466
+ parts_list.append(dims[0:idx0])
467
+ parts_list.append(dim)
468
+ if idx0 + 1 < nindex:
469
+ parts_list.append(dims[idx0 + 1 :])
470
+ if not parts_list:
471
+ return dim
472
+ result2 = parts_list[0]
473
+ for p in parts_list[1:]:
474
+ result2 = unit_c(result2, p)
475
+ return result2
476
+
477
+
478
+ def _update_col(col: Any, added_col: int) -> Any:
479
+ """Shift *col* if a new column was inserted before or at it."""
480
+ if isinstance(col, (list, tuple)) and len(col) == 2:
481
+ if added_col <= col[1]:
482
+ return [col[0], col[1] + 1]
483
+ return col
484
+ if added_col <= col:
485
+ return col + 1
486
+ return col
487
+
488
+
489
+ def _update_row(row: Any, added_row: int) -> Any:
490
+ """Shift *row* if a new row was inserted before or at it."""
491
+ if isinstance(row, (list, tuple)) and len(row) == 2:
492
+ if added_row <= row[1]:
493
+ return [row[0], row[1] + 1]
494
+ return row
495
+ if added_row <= row:
496
+ return row + 1
497
+ return row
498
+
499
+
500
+ # ---------------------------------------------------------------------------
501
+ # pack_grob / grid_pack
502
+ # ---------------------------------------------------------------------------
503
+
504
+
505
+ def pack_grob(
506
+ frame: GTree,
507
+ grob: Grob,
508
+ side: Optional[str] = None,
509
+ row: Optional[Any] = None,
510
+ row_before: Optional[int] = None,
511
+ row_after: Optional[int] = None,
512
+ col: Optional[Any] = None,
513
+ col_before: Optional[int] = None,
514
+ col_after: Optional[int] = None,
515
+ width: Optional[Unit] = None,
516
+ height: Optional[Unit] = None,
517
+ force_width: bool = False,
518
+ force_height: bool = False,
519
+ border: Optional[Sequence[Unit]] = None,
520
+ dynamic: bool = False,
521
+ ) -> GTree:
522
+ """Pack a grob into a frame, returning a new frame.
523
+
524
+ This function is the Python equivalent of R's ``packGrob``. It manages
525
+ the frame's internal layout, adding rows/columns as needed, and wraps
526
+ the child grob in a ``cellGrob``.
527
+
528
+ Parameters
529
+ ----------
530
+ frame : GTree
531
+ The frame grob (must have ``_grid_class="frame"``).
532
+ grob : Grob
533
+ The child grob to pack.
534
+ side : str or None
535
+ One of ``"left"``, ``"right"``, ``"top"``, ``"bottom"``.
536
+ row : int, list of int, or None
537
+ Row position or range.
538
+ row_before : int or None
539
+ Insert before this row.
540
+ row_after : int or None
541
+ Insert after this row.
542
+ col : int, list of int, or None
543
+ Column position or range.
544
+ col_before : int or None
545
+ Insert before this column.
546
+ col_after : int or None
547
+ Insert after this column.
548
+ width : Unit or None
549
+ Explicit width (derived from *grob* if ``None``).
550
+ height : Unit or None
551
+ Explicit height (derived from *grob* if ``None``).
552
+ force_width : bool
553
+ If ``True``, override the existing column width.
554
+ force_height : bool
555
+ If ``True``, override the existing row height.
556
+ border : list of Unit or None
557
+ Four-element border insets ``[bottom, left, top, right]``.
558
+ dynamic : bool
559
+ If ``True``, use a grob path for deferred sizing.
560
+
561
+ Returns
562
+ -------
563
+ GTree
564
+ A new frame with the child packed in.
565
+
566
+ Raises
567
+ ------
568
+ TypeError
569
+ If *frame* is not a frame grob or *grob* is not a grob.
570
+ ValueError
571
+ If the row/column specification is invalid.
572
+ """
573
+ if not isinstance(frame, GTree) or getattr(frame, "_grid_class", None) != "frame":
574
+ raise TypeError("invalid 'frame'")
575
+ if not is_grob(grob):
576
+ raise TypeError("invalid 'grob'")
577
+
578
+ # Normalise col/row ranges
579
+ col_range = False
580
+ row_range = False
581
+ if col is not None and isinstance(col, (list, tuple)) and len(col) > 1:
582
+ col = [min(col), max(col)]
583
+ col_range = True
584
+ if row is not None and isinstance(row, (list, tuple)) and len(row) > 1:
585
+ row = [min(row), max(row)]
586
+ row_range = True
587
+
588
+ # Get current layout dimensions
589
+ frame = copy.deepcopy(frame)
590
+ frame_vp = getattr(frame, "framevp", None)
591
+ if frame_vp is None:
592
+ frame_vp = Viewport()
593
+ lay = getattr(frame_vp, "layout", None)
594
+ if lay is None:
595
+ ncol = 0
596
+ nrow = 0
597
+ else:
598
+ ncol = layout_ncol(lay)
599
+ nrow = layout_nrow(lay)
600
+
601
+ # (i) Validate location specifications
602
+ ncs = _num_col_specs(side, col, col_before, col_after)
603
+ if ncs == 0:
604
+ if ncol > 0:
605
+ col = [1, ncol]
606
+ col_range = True
607
+ else:
608
+ col = 1
609
+ ncs = 1
610
+ if ncs != 1:
611
+ raise ValueError(
612
+ "cannot specify more than one of 'side=[left/right]', "
613
+ "'col', 'col_before', or 'col_after'"
614
+ )
615
+
616
+ nrs = _num_row_specs(side, row, row_before, row_after)
617
+ if nrs == 0:
618
+ if nrow > 0:
619
+ row = [1, nrow]
620
+ row_range = True
621
+ else:
622
+ row = 1
623
+ nrs = 1
624
+ if nrs != 1:
625
+ raise ValueError(
626
+ "must specify exactly one of 'side=[top/bottom]', "
627
+ "'row', 'row_before', or 'row_after'"
628
+ )
629
+
630
+ # (ii) Determine location
631
+ is_new_col = _new_col(side, col, col_before, col_after, ncol)
632
+ col = _col_spec(side, col, col_before, col_after, ncol)
633
+ is_new_row = _new_row(side, row, row_before, row_after, nrow)
634
+ row = _row_spec(side, row, row_before, row_after, nrow)
635
+
636
+ # Build cell grob
637
+ cgrob: Optional[GTree] = None
638
+ if grob is not None:
639
+ cgrob = _cell_grob(col, row, border, grob, dynamic,
640
+ _cell_viewport(col, row, border))
641
+
642
+ # (iii) Default width/height from the grob
643
+ # R frames.R:399-414: if grob is present, use grobwidth/grobheight;
644
+ # if grob is NULL, use unit(1, "null")
645
+ if width is None:
646
+ if grob is None:
647
+ width = Unit(1, "null")
648
+ else:
649
+ # R: unit(1, "grobwidth", cgrob) or gPath variant when dynamic
650
+ width = Unit(1, "grobwidth", data=cgrob)
651
+ if height is None:
652
+ if grob is None:
653
+ height = Unit(1, "null")
654
+ else:
655
+ height = Unit(1, "grobheight", data=cgrob)
656
+
657
+ # Include border in width/height
658
+ if border is not None:
659
+ width = border[1] + width + border[3]
660
+ height = border[0] + height + border[2]
661
+
662
+ # (iv) Update layout
663
+ if is_new_col:
664
+ ncol += 1
665
+ if is_new_row:
666
+ nrow += 1
667
+
668
+ if lay is None:
669
+ widths = width
670
+ heights = height
671
+ else:
672
+ if col_range:
673
+ widths = layout_widths(lay)
674
+ else:
675
+ widths = _mod_dims(width, layout_widths(lay), col, is_new_col,
676
+ ncol, force_width)
677
+ if row_range:
678
+ heights = layout_heights(lay)
679
+ else:
680
+ heights = _mod_dims(height, layout_heights(lay), row, is_new_row,
681
+ nrow, force_height)
682
+
683
+ frame_vp._layout = GridLayout(nrow=nrow, ncol=ncol,
684
+ widths=widths, heights=heights)
685
+
686
+ # Shift existing children if new row/col was added
687
+ if is_new_col or is_new_row:
688
+ for child_name in list(frame._children_order):
689
+ child = frame._children[child_name]
690
+ if is_new_col:
691
+ new_c = _update_col(getattr(child, "col", 1), col)
692
+ child.col = new_c
693
+ child.cellvp = _cell_viewport(new_c, getattr(child, "row", 1),
694
+ getattr(child, "border", None))
695
+ if is_new_row:
696
+ new_r = _update_row(getattr(child, "row", 1), row)
697
+ child.row = new_r
698
+ child.cellvp = _cell_viewport(getattr(child, "col", 1), new_r,
699
+ getattr(child, "border", None))
700
+
701
+ # Add the new grob
702
+ if cgrob is not None:
703
+ frame.add_child(cgrob)
704
+
705
+ frame.framevp = frame_vp
706
+ return frame
707
+
708
+
709
+ def grid_pack(
710
+ frame: GTree,
711
+ grob: Grob,
712
+ redraw: bool = True,
713
+ side: Optional[str] = None,
714
+ row: Optional[Any] = None,
715
+ row_before: Optional[int] = None,
716
+ row_after: Optional[int] = None,
717
+ col: Optional[Any] = None,
718
+ col_before: Optional[int] = None,
719
+ col_after: Optional[int] = None,
720
+ width: Optional[Unit] = None,
721
+ height: Optional[Unit] = None,
722
+ force_width: bool = False,
723
+ force_height: bool = False,
724
+ border: Optional[Sequence[Unit]] = None,
725
+ dynamic: bool = False,
726
+ ) -> GTree:
727
+ """Pack a grob into a frame and optionally redraw.
728
+
729
+ This is the display-list-aware wrapper around :func:`pack_grob`.
730
+
731
+ Parameters
732
+ ----------
733
+ frame : GTree
734
+ The frame grob.
735
+ grob : Grob
736
+ The child grob to pack.
737
+ redraw : bool
738
+ If ``True``, redraw after packing.
739
+ side : str or None
740
+ Side specification.
741
+ row, row_before, row_after : int or None
742
+ Row specification.
743
+ col, col_before, col_after : int or None
744
+ Column specification.
745
+ width : Unit or None
746
+ Explicit width.
747
+ height : Unit or None
748
+ Explicit height.
749
+ force_width : bool
750
+ Override existing column width.
751
+ force_height : bool
752
+ Override existing row height.
753
+ border : list of Unit or None
754
+ Border insets.
755
+ dynamic : bool
756
+ Use dynamic sizing.
757
+
758
+ Returns
759
+ -------
760
+ GTree
761
+ The updated frame.
762
+ """
763
+ result = pack_grob(
764
+ frame, grob,
765
+ side=side,
766
+ row=row, row_before=row_before, row_after=row_after,
767
+ col=col, col_before=col_before, col_after=col_after,
768
+ width=width, height=height,
769
+ force_width=force_width, force_height=force_height,
770
+ border=border, dynamic=dynamic,
771
+ )
772
+ if redraw:
773
+ grid_draw(result)
774
+ return result
775
+
776
+
777
+ # ---------------------------------------------------------------------------
778
+ # place_grob / grid_place
779
+ # ---------------------------------------------------------------------------
780
+
781
+
782
+ def place_grob(
783
+ frame: GTree,
784
+ grob: Grob,
785
+ row: Optional[Any] = None,
786
+ col: Optional[Any] = None,
787
+ ) -> GTree:
788
+ """Place a grob into an existing cell of a frame.
789
+
790
+ Unlike :func:`pack_grob`, this does **not** create new rows or columns;
791
+ it only places the grob into an already-existing cell.
792
+
793
+ Parameters
794
+ ----------
795
+ frame : GTree
796
+ The frame grob (must have ``_grid_class="frame"``).
797
+ grob : Grob
798
+ The child grob.
799
+ row : int, list of int, or None
800
+ Row position (defaults to full range).
801
+ col : int, list of int, or None
802
+ Column position (defaults to full range).
803
+
804
+ Returns
805
+ -------
806
+ GTree
807
+ A new frame with the grob placed.
808
+
809
+ Raises
810
+ ------
811
+ TypeError
812
+ If *frame* is not a frame grob or *grob* is not a grob.
813
+ ValueError
814
+ If *row*/*col* are out of range.
815
+ """
816
+ if not isinstance(frame, GTree) or getattr(frame, "_grid_class", None) != "frame":
817
+ raise TypeError("invalid 'frame'")
818
+ if not is_grob(grob):
819
+ raise TypeError("invalid 'grob'")
820
+
821
+ dim = _frame_dim(frame)
822
+ if row is None:
823
+ row = [1, dim[0]]
824
+ if col is None:
825
+ col = [1, dim[1]]
826
+
827
+ # Validate
828
+ row_vals = row if isinstance(row, (list, tuple)) else [row]
829
+ col_vals = col if isinstance(col, (list, tuple)) else [col]
830
+ if min(row_vals) < 1 or max(row_vals) > dim[0]:
831
+ raise ValueError("invalid 'row' (no such row in frame layout)")
832
+ if min(col_vals) < 1 or max(col_vals) > dim[1]:
833
+ raise ValueError("invalid 'col' (no such col in frame layout)")
834
+
835
+ cgrob = _cell_grob(col, row, None, grob, False,
836
+ _cell_viewport(col, row, None))
837
+ return add_grob(frame, cgrob)
838
+
839
+
840
+ def grid_place(
841
+ frame: GTree,
842
+ grob: Grob,
843
+ row: int = 1,
844
+ col: int = 1,
845
+ redraw: bool = True,
846
+ ) -> GTree:
847
+ """Place a grob into an existing cell of a frame and optionally redraw.
848
+
849
+ Parameters
850
+ ----------
851
+ frame : GTree
852
+ The frame grob.
853
+ grob : Grob
854
+ The child grob.
855
+ row : int
856
+ Row position.
857
+ col : int
858
+ Column position.
859
+ redraw : bool
860
+ If ``True``, redraw after placing.
861
+
862
+ Returns
863
+ -------
864
+ GTree
865
+ The updated frame.
866
+ """
867
+ result = place_grob(frame, grob, row=row, col=col)
868
+ if redraw:
869
+ grid_draw(result)
870
+ return result
871
+
872
+
873
+ # =========================================================================
874
+ # Components (components.R)
875
+ # =========================================================================
876
+
877
+
878
+ # ---------------------------------------------------------------------------
879
+ # X-axis internals
880
+ # ---------------------------------------------------------------------------
881
+
882
+
883
+ def _make_xaxis_major(at: Sequence[float], main: bool) -> Grob:
884
+ """Create the major line for an x-axis."""
885
+ y = [0, 0] if main else [1, 1]
886
+ return lines_grob(
887
+ x=Unit([min(at), max(at)], "native"),
888
+ y=Unit(y, "npc"),
889
+ name="major",
890
+ )
891
+
892
+
893
+ def _make_xaxis_ticks(at: Sequence[float], main: bool) -> Grob:
894
+ """Create tick marks for an x-axis."""
895
+ if main:
896
+ tick_y0 = Unit(0, "npc")
897
+ tick_y1 = Unit(-0.5, "lines")
898
+ else:
899
+ tick_y0 = Unit(1, "npc")
900
+ tick_y1 = Unit(1, "npc") + Unit(0.5, "lines")
901
+ return segments_grob(
902
+ x0=Unit(list(at), "native"),
903
+ y0=tick_y0,
904
+ x1=Unit(list(at), "native"),
905
+ y1=tick_y1,
906
+ name="ticks",
907
+ )
908
+
909
+
910
+ def _make_xaxis_labels(
911
+ at: Sequence[float],
912
+ label: Any,
913
+ main: bool,
914
+ ) -> Grob:
915
+ """Create tick labels for an x-axis."""
916
+ label_y = Unit(-1.5, "lines") if main else Unit(1, "npc") + Unit(1.5, "lines")
917
+ labels = [str(a) for a in at] if isinstance(label, bool) else list(label)
918
+ return text_grob(
919
+ label=labels,
920
+ x=Unit(list(at), "native"),
921
+ y=label_y,
922
+ just="centre",
923
+ rot=0,
924
+ name="labels",
925
+ )
926
+
927
+
928
+ def _update_xlabels(x: GTree) -> GTree:
929
+ """Add or remove x-axis labels depending on label specification."""
930
+ lab = getattr(x, "label", True)
931
+ if isinstance(lab, bool) and not lab:
932
+ try:
933
+ return remove_grob(x, "labels")
934
+ except KeyError:
935
+ return x
936
+ return add_grob(x, _make_xaxis_labels(x.at, x.label, x.main))
937
+
938
+
939
+ # ---------------------------------------------------------------------------
940
+ # Y-axis internals
941
+ # ---------------------------------------------------------------------------
942
+
943
+
944
+ def _make_yaxis_major(at: Sequence[float], main: bool) -> Grob:
945
+ """Create the major line for a y-axis."""
946
+ x = [0, 0] if main else [1, 1]
947
+ return lines_grob(
948
+ x=Unit(x, "npc"),
949
+ y=Unit([min(at), max(at)], "native"),
950
+ name="major",
951
+ )
952
+
953
+
954
+ def _make_yaxis_ticks(at: Sequence[float], main: bool) -> Grob:
955
+ """Create tick marks for a y-axis."""
956
+ if main:
957
+ tick_x0 = Unit(0, "npc")
958
+ tick_x1 = Unit(-0.5, "lines")
959
+ else:
960
+ tick_x0 = Unit(1, "npc")
961
+ tick_x1 = Unit(1, "npc") + Unit(0.5, "lines")
962
+ return segments_grob(
963
+ x0=tick_x0,
964
+ y0=Unit(list(at), "native"),
965
+ x1=tick_x1,
966
+ y1=Unit(list(at), "native"),
967
+ name="ticks",
968
+ )
969
+
970
+
971
+ def _make_yaxis_labels(
972
+ at: Sequence[float],
973
+ label: Any,
974
+ main: bool,
975
+ ) -> Grob:
976
+ """Create tick labels for a y-axis."""
977
+ if main:
978
+ hjust = "right"
979
+ label_x = Unit(-1, "lines")
980
+ else:
981
+ hjust = "left"
982
+ label_x = Unit(1, "npc") + Unit(1, "lines")
983
+ labels = [str(a) for a in at] if isinstance(label, bool) else list(label)
984
+ return text_grob(
985
+ label=labels,
986
+ x=label_x,
987
+ y=Unit(list(at), "native"),
988
+ just=[hjust, "centre"],
989
+ rot=0,
990
+ name="labels",
991
+ )
992
+
993
+
994
+ def _update_ylabels(x: GTree) -> GTree:
995
+ """Add or remove y-axis labels depending on label specification."""
996
+ lab = getattr(x, "label", True)
997
+ if isinstance(lab, bool) and not lab:
998
+ try:
999
+ return remove_grob(x, "labels")
1000
+ except KeyError:
1001
+ return x
1002
+ return add_grob(x, _make_yaxis_labels(x.at, x.label, x.main))
1003
+
1004
+
1005
+ class _XAxisGTree(GTree):
1006
+ """GTree subclass for x-axis with on-the-fly tick generation."""
1007
+
1008
+ def __init__(self, at, label, main, edits, **kwargs):
1009
+ super().__init__(_grid_class="xaxis", at=at, label=label,
1010
+ main=main, edits=edits, **kwargs)
1011
+
1012
+ def make_content(self):
1013
+ at = getattr(self, "at", None)
1014
+ if at is None:
1015
+ from ._viewport import current_viewport
1016
+ vp = current_viewport()
1017
+ xscale = getattr(vp, "_xscale", None) or getattr(vp, "xscale", [0, 1])
1018
+ at = grid_pretty(xscale)
1019
+ self.at = at
1020
+ main = getattr(self, "main", True)
1021
+ label = getattr(self, "label", True)
1022
+ self = add_grob(self, _make_xaxis_major(at, main))
1023
+ self = add_grob(self, _make_xaxis_ticks(at, main))
1024
+ self = _update_xlabels(self)
1025
+ edits = getattr(self, "edits", None)
1026
+ if edits is not None:
1027
+ self = apply_edits(self, edits)
1028
+ return self
1029
+
1030
+
1031
+ class _YAxisGTree(GTree):
1032
+ """GTree subclass for y-axis with on-the-fly tick generation."""
1033
+
1034
+ def __init__(self, at, label, main, edits, **kwargs):
1035
+ super().__init__(_grid_class="yaxis", at=at, label=label,
1036
+ main=main, edits=edits, **kwargs)
1037
+
1038
+ def make_content(self):
1039
+ at = getattr(self, "at", None)
1040
+ if at is None:
1041
+ from ._viewport import current_viewport
1042
+ vp = current_viewport()
1043
+ yscale = getattr(vp, "_yscale", None) or getattr(vp, "yscale", [0, 1])
1044
+ at = grid_pretty(yscale)
1045
+ self.at = at
1046
+ main = getattr(self, "main", True)
1047
+ label = getattr(self, "label", True)
1048
+ self = add_grob(self, _make_yaxis_major(at, main))
1049
+ self = add_grob(self, _make_yaxis_ticks(at, main))
1050
+ self = _update_ylabels(self)
1051
+ edits = getattr(self, "edits", None)
1052
+ if edits is not None:
1053
+ self = apply_edits(self, edits)
1054
+ return self
1055
+
1056
+
1057
+ # ---------------------------------------------------------------------------
1058
+ # Public axis constructors
1059
+ # ---------------------------------------------------------------------------
1060
+
1061
+
1062
+ def xaxis_grob(
1063
+ at: Optional[Sequence[float]] = None,
1064
+ label: Any = True,
1065
+ main: bool = True,
1066
+ edits: Optional[Any] = None,
1067
+ name: Optional[str] = None,
1068
+ gp: Optional[Gpar] = None,
1069
+ vp: Optional[Any] = None,
1070
+ ) -> GTree:
1071
+ """Create an x-axis grob.
1072
+
1073
+ Parameters
1074
+ ----------
1075
+ at : sequence of float or None
1076
+ Tick positions in native coordinates. If ``None``, tick positions
1077
+ are calculated on-the-fly when the grob is drawn.
1078
+ label : bool or sequence of str
1079
+ If ``True`` (default), labels are derived from *at*. If ``False``,
1080
+ no labels are drawn. A sequence provides explicit labels.
1081
+ main : bool
1082
+ If ``True`` (default), the axis is drawn on the bottom.
1083
+ edits : GEdit, GEditList, or None
1084
+ Edits to apply to child grobs.
1085
+ name : str or None
1086
+ Grob name.
1087
+ gp : Gpar or None
1088
+ Graphical parameters.
1089
+ vp : object or None
1090
+ Viewport.
1091
+
1092
+ Returns
1093
+ -------
1094
+ GTree
1095
+ A GTree with ``_grid_class="xaxis"``.
1096
+ """
1097
+ return grid_xaxis(at=at, label=label, main=main, edits=edits,
1098
+ name=name, gp=gp, draw=False, vp=vp)
1099
+
1100
+
1101
+ def grid_xaxis(
1102
+ at: Optional[Sequence[float]] = None,
1103
+ label: Any = True,
1104
+ main: bool = True,
1105
+ edits: Optional[Any] = None,
1106
+ name: Optional[str] = None,
1107
+ gp: Optional[Gpar] = None,
1108
+ draw: bool = True,
1109
+ vp: Optional[Any] = None,
1110
+ ) -> GTree:
1111
+ """Create and optionally draw an x-axis.
1112
+
1113
+ Parameters
1114
+ ----------
1115
+ at : sequence of float or None
1116
+ Tick positions in native coordinates.
1117
+ label : bool or sequence of str
1118
+ Label specification.
1119
+ main : bool
1120
+ If ``True``, the axis is on the bottom.
1121
+ edits : GEdit, GEditList, or None
1122
+ Edits to apply to children.
1123
+ name : str or None
1124
+ Grob name.
1125
+ gp : Gpar or None
1126
+ Graphical parameters.
1127
+ draw : bool
1128
+ If ``True``, draw immediately.
1129
+ vp : object or None
1130
+ Viewport.
1131
+
1132
+ Returns
1133
+ -------
1134
+ GTree
1135
+ A GTree with ``_grid_class="xaxis"``.
1136
+ """
1137
+ if at is None:
1138
+ major = None
1139
+ ticks = None
1140
+ labels = None
1141
+ else:
1142
+ at = [float(a) for a in at]
1143
+ major = _make_xaxis_major(at, main)
1144
+ ticks = _make_xaxis_ticks(at, main)
1145
+ if isinstance(label, bool) and not label:
1146
+ labels = None
1147
+ else:
1148
+ labels = _make_xaxis_labels(at, label, main)
1149
+
1150
+ children_list = [g for g in (major, ticks, labels) if g is not None]
1151
+ xg = _XAxisGTree(
1152
+ at=at, label=label, main=main, edits=edits,
1153
+ children=GList(*children_list) if children_list else None,
1154
+ name=name, gp=gp, vp=vp,
1155
+ )
1156
+ if edits is not None:
1157
+ xg = apply_edits(xg, edits)
1158
+ if draw:
1159
+ grid_draw(xg)
1160
+ return xg
1161
+
1162
+
1163
+ def yaxis_grob(
1164
+ at: Optional[Sequence[float]] = None,
1165
+ label: Any = True,
1166
+ main: bool = True,
1167
+ edits: Optional[Any] = None,
1168
+ name: Optional[str] = None,
1169
+ gp: Optional[Gpar] = None,
1170
+ vp: Optional[Any] = None,
1171
+ ) -> GTree:
1172
+ """Create a y-axis grob.
1173
+
1174
+ Parameters
1175
+ ----------
1176
+ at : sequence of float or None
1177
+ Tick positions in native coordinates. If ``None``, tick positions
1178
+ are calculated on-the-fly when the grob is drawn.
1179
+ label : bool or sequence of str
1180
+ Label specification.
1181
+ main : bool
1182
+ If ``True`` (default), the axis is drawn on the left.
1183
+ edits : GEdit, GEditList, or None
1184
+ Edits to apply to children.
1185
+ name : str or None
1186
+ Grob name.
1187
+ gp : Gpar or None
1188
+ Graphical parameters.
1189
+ vp : object or None
1190
+ Viewport.
1191
+
1192
+ Returns
1193
+ -------
1194
+ GTree
1195
+ A GTree with ``_grid_class="yaxis"``.
1196
+ """
1197
+ return grid_yaxis(at=at, label=label, main=main, edits=edits,
1198
+ name=name, gp=gp, draw=False, vp=vp)
1199
+
1200
+
1201
+ def grid_yaxis(
1202
+ at: Optional[Sequence[float]] = None,
1203
+ label: Any = True,
1204
+ main: bool = True,
1205
+ edits: Optional[Any] = None,
1206
+ name: Optional[str] = None,
1207
+ gp: Optional[Gpar] = None,
1208
+ draw: bool = True,
1209
+ vp: Optional[Any] = None,
1210
+ ) -> GTree:
1211
+ """Create and optionally draw a y-axis.
1212
+
1213
+ Parameters
1214
+ ----------
1215
+ at : sequence of float or None
1216
+ Tick positions in native coordinates.
1217
+ label : bool or sequence of str
1218
+ Label specification.
1219
+ main : bool
1220
+ If ``True``, the axis is on the left.
1221
+ edits : GEdit, GEditList, or None
1222
+ Edits to apply to children.
1223
+ name : str or None
1224
+ Grob name.
1225
+ gp : Gpar or None
1226
+ Graphical parameters.
1227
+ draw : bool
1228
+ If ``True``, draw immediately.
1229
+ vp : object or None
1230
+ Viewport.
1231
+
1232
+ Returns
1233
+ -------
1234
+ GTree
1235
+ A GTree with ``_grid_class="yaxis"``.
1236
+ """
1237
+ if at is None:
1238
+ major = None
1239
+ ticks = None
1240
+ labels = None
1241
+ else:
1242
+ at = [float(a) for a in at]
1243
+ major = _make_yaxis_major(at, main)
1244
+ ticks = _make_yaxis_ticks(at, main)
1245
+ if isinstance(label, bool) and not label:
1246
+ labels = None
1247
+ else:
1248
+ labels = _make_yaxis_labels(at, label, main)
1249
+
1250
+ children_list = [g for g in (major, ticks, labels) if g is not None]
1251
+ yg = _YAxisGTree(
1252
+ at=at, label=label, main=main, edits=edits,
1253
+ children=GList(*children_list) if children_list else None,
1254
+ name=name, gp=gp, vp=vp,
1255
+ )
1256
+ if edits is not None:
1257
+ yg = apply_edits(yg, edits)
1258
+ if draw:
1259
+ grid_draw(yg)
1260
+ return yg
1261
+
1262
+
1263
+ # ---------------------------------------------------------------------------
1264
+ # Legend
1265
+ # ---------------------------------------------------------------------------
1266
+
1267
+
1268
+ def legend_grob(
1269
+ labels: Sequence[str],
1270
+ nrow: Optional[int] = None,
1271
+ ncol: Optional[int] = None,
1272
+ byrow: bool = False,
1273
+ do_lines: bool = True,
1274
+ do_points: bool = True,
1275
+ lines_first: bool = True,
1276
+ pch: Optional[Sequence[int]] = None,
1277
+ hgap: Any = None,
1278
+ vgap: Any = None,
1279
+ default_units: str = "lines",
1280
+ gp: Optional[Gpar] = None,
1281
+ vp: Optional[Any] = None,
1282
+ ) -> GTree:
1283
+ """Create a legend grob.
1284
+
1285
+ This is the Python port of R's ``legendGrob``. It builds a frame
1286
+ grob containing symbol and text entries arranged in a grid.
1287
+
1288
+ Parameters
1289
+ ----------
1290
+ labels : sequence of str
1291
+ Legend entry labels.
1292
+ nrow : int or None
1293
+ Number of rows. If ``None`` and *ncol* is also ``None``, defaults
1294
+ to ``len(labels)`` with ``ncol=1``.
1295
+ ncol : int or None
1296
+ Number of columns.
1297
+ byrow : bool
1298
+ If ``True``, fill by row; otherwise by column.
1299
+ do_lines : bool
1300
+ If ``True``, draw lines in the symbol column.
1301
+ do_points : bool
1302
+ If ``True``, draw points in the symbol column.
1303
+ lines_first : bool
1304
+ If ``True``, draw lines before points.
1305
+ pch : sequence of int or None
1306
+ Point characters.
1307
+ hgap : Unit or numeric or None
1308
+ Horizontal gap between columns. Defaults to ``Unit(1, "lines")``.
1309
+ vgap : Unit or numeric or None
1310
+ Vertical gap between rows. Defaults to ``Unit(1, "lines")``.
1311
+ default_units : str
1312
+ Unit type for bare numerics in *hgap* / *vgap*.
1313
+ gp : Gpar or None
1314
+ Graphical parameters (may contain ``col``, ``lty``, ``lwd``, ``fill``).
1315
+ vp : object or None
1316
+ Viewport.
1317
+
1318
+ Returns
1319
+ -------
1320
+ GTree
1321
+ A frame grob containing the legend entries.
1322
+ """
1323
+ labels = [str(lb) for lb in labels]
1324
+ nkeys = len(labels)
1325
+ if nkeys == 0:
1326
+ return null_grob(vp=vp) # type: ignore[return-value]
1327
+
1328
+ # Defaults
1329
+ if hgap is None:
1330
+ hgap = Unit(1, "lines")
1331
+ elif not is_unit(hgap):
1332
+ hgap = Unit(hgap, default_units)
1333
+ if vgap is None:
1334
+ vgap = Unit(1, "lines")
1335
+ elif not is_unit(vgap):
1336
+ vgap = Unit(vgap, default_units)
1337
+
1338
+ # nrow / ncol defaults
1339
+ if nrow is not None and nrow < 1:
1340
+ raise ValueError("'nrow' must be >= 1")
1341
+ if ncol is not None and ncol < 1:
1342
+ raise ValueError("'ncol' must be >= 1")
1343
+
1344
+ if nrow is None and ncol is None:
1345
+ ncol = 1
1346
+ nrow = nkeys
1347
+ elif nrow is None:
1348
+ nrow = math.ceil(nkeys / ncol) # type: ignore[arg-type]
1349
+ elif ncol is None:
1350
+ ncol = math.ceil(nkeys / nrow)
1351
+ if nrow * ncol < nkeys: # type: ignore[operator]
1352
+ raise ValueError("nrow * ncol < number of legend labels")
1353
+
1354
+ # Recycle pch
1355
+ has_pch = pch is not None and len(pch) > 0
1356
+ if has_pch:
1357
+ pch_list = list(pch) # type: ignore[arg-type]
1358
+ while len(pch_list) < nkeys:
1359
+ pch_list = pch_list * (nkeys // len(pch_list) + 1)
1360
+ pch_list = pch_list[:nkeys]
1361
+ else:
1362
+ pch_list = []
1363
+
1364
+ # Extract per-key gp components
1365
+ gp_dict: Dict[str, Any] = {}
1366
+ if gp is not None:
1367
+ for attr in ("lty", "lwd", "col", "fill"):
1368
+ val = getattr(gp, attr, None)
1369
+ if val is not None:
1370
+ if isinstance(val, (list, tuple, np.ndarray)):
1371
+ lst = list(val)
1372
+ while len(lst) < nkeys:
1373
+ lst = lst * (nkeys // len(lst) + 1)
1374
+ gp_dict[attr] = lst[:nkeys]
1375
+ else:
1376
+ gp_dict[attr] = [val] * nkeys
1377
+
1378
+ u0 = Unit(0, "npc")
1379
+ u1 = Unit(1, "char")
1380
+
1381
+ fg = frame_grob(vp=vp)
1382
+
1383
+ for i in range(nkeys):
1384
+ if byrow:
1385
+ ci = 1 + (i % ncol) # type: ignore[operator]
1386
+ ri = 1 + (i // ncol) # type: ignore[operator]
1387
+ else:
1388
+ ci = 1 + (i // nrow)
1389
+ ri = 1 + (i % nrow)
1390
+
1391
+ # Build per-key gp
1392
+ gpi_kwargs: Dict[str, Any] = {}
1393
+ for attr in ("lty", "lwd", "col", "fill"):
1394
+ if attr in gp_dict:
1395
+ gpi_kwargs[attr] = gp_dict[attr][i]
1396
+ gpi = Gpar(**gpi_kwargs) if gpi_kwargs else (gp if gp is not None else Gpar())
1397
+
1398
+ # Borders
1399
+ vg = vgap if ri != nrow else u0
1400
+ symbol_border = [vg, u0, u0, hgap * 0.5]
1401
+ text_border = [vg, u0, u0, hgap if ci != ncol else u0] # type: ignore[operator]
1402
+
1403
+ # Points/lines grob
1404
+ if has_pch and do_lines:
1405
+ line_g = lines_grob(x=Unit([0, 1], "npc"), y=Unit(0.5, "npc"), gp=gpi)
1406
+ point_g = points_grob(
1407
+ x=Unit(0.5, "npc"), y=Unit(0.5, "npc"),
1408
+ pch=pch_list[i], gp=gpi,
1409
+ )
1410
+ if lines_first:
1411
+ pl_grob: Grob = GTree(children=GList(line_g, point_g))
1412
+ else:
1413
+ pl_grob = GTree(children=GList(point_g, line_g))
1414
+ elif has_pch:
1415
+ pl_grob = points_grob(
1416
+ x=Unit(0.5, "npc"), y=Unit(0.5, "npc"),
1417
+ pch=pch_list[i], gp=gpi,
1418
+ )
1419
+ elif do_lines:
1420
+ pl_grob = lines_grob(x=Unit([0, 1], "npc"), y=Unit(0.5, "npc"), gp=gpi)
1421
+ else:
1422
+ pl_grob = null_grob()
1423
+
1424
+ fg = pack_grob(
1425
+ fg, pl_grob,
1426
+ col=2 * ci - 1, row=ri,
1427
+ border=symbol_border,
1428
+ width=u1, height=u1,
1429
+ force_width=True,
1430
+ )
1431
+
1432
+ # Text grob
1433
+ gpi_text = Gpar(col="black")
1434
+ fg = pack_grob(
1435
+ fg,
1436
+ text_grob(label=labels[i], x=Unit(0, "npc"), y=Unit(0.5, "npc"),
1437
+ just=["left", "centre"], gp=gpi_text),
1438
+ col=2 * ci, row=ri,
1439
+ border=text_border,
1440
+ )
1441
+
1442
+ return fg
1443
+
1444
+
1445
+ def grid_legend(
1446
+ labels: Sequence[str],
1447
+ nrow: Optional[int] = None,
1448
+ ncol: Optional[int] = None,
1449
+ byrow: bool = False,
1450
+ do_lines: bool = True,
1451
+ do_points: bool = True,
1452
+ lines_first: bool = True,
1453
+ pch: Optional[Sequence[int]] = None,
1454
+ hgap: Any = None,
1455
+ vgap: Any = None,
1456
+ default_units: str = "lines",
1457
+ gp: Optional[Gpar] = None,
1458
+ vp: Optional[Any] = None,
1459
+ draw: bool = True,
1460
+ ) -> GTree:
1461
+ """Create and optionally draw a legend.
1462
+
1463
+ Parameters
1464
+ ----------
1465
+ labels : sequence of str
1466
+ Legend entry labels.
1467
+ nrow : int or None
1468
+ Number of rows.
1469
+ ncol : int or None
1470
+ Number of columns.
1471
+ byrow : bool
1472
+ Fill by row.
1473
+ do_lines : bool
1474
+ Draw lines in symbol column.
1475
+ do_points : bool
1476
+ Draw points in symbol column.
1477
+ lines_first : bool
1478
+ Draw lines before points.
1479
+ pch : sequence of int or None
1480
+ Point characters.
1481
+ hgap : Unit or numeric or None
1482
+ Horizontal gap.
1483
+ vgap : Unit or numeric or None
1484
+ Vertical gap.
1485
+ default_units : str
1486
+ Default unit type.
1487
+ gp : Gpar or None
1488
+ Graphical parameters.
1489
+ vp : object or None
1490
+ Viewport.
1491
+ draw : bool
1492
+ If ``True``, draw immediately.
1493
+
1494
+ Returns
1495
+ -------
1496
+ GTree
1497
+ The legend grob.
1498
+ """
1499
+ g = legend_grob(
1500
+ labels, nrow=nrow, ncol=ncol, byrow=byrow,
1501
+ do_lines=do_lines, do_points=do_points,
1502
+ lines_first=lines_first, pch=pch,
1503
+ hgap=hgap, vgap=vgap, default_units=default_units,
1504
+ gp=gp, vp=vp,
1505
+ )
1506
+ if draw:
1507
+ grid_draw(g)
1508
+ return g
1509
+
1510
+
1511
+ # =========================================================================
1512
+ # High-level functions (highlevel.R)
1513
+ # =========================================================================
1514
+
1515
+
1516
+ def grid_grill(
1517
+ h: Optional[Any] = None,
1518
+ v: Optional[Any] = None,
1519
+ default_units: str = "npc",
1520
+ gp: Optional[Gpar] = None,
1521
+ vp: Optional[Any] = None,
1522
+ ) -> None:
1523
+ """Draw a grid of horizontal and vertical lines (background grid).
1524
+
1525
+ Parameters
1526
+ ----------
1527
+ h : Unit, numeric, or None
1528
+ Horizontal line positions. Defaults to ``[0.25, 0.5, 0.75]`` in
1529
+ NPC coordinates.
1530
+ v : Unit, numeric, or None
1531
+ Vertical line positions. Defaults to ``[0.25, 0.5, 0.75]`` in
1532
+ NPC coordinates.
1533
+ default_units : str
1534
+ Unit type for bare numerics.
1535
+ gp : Gpar or None
1536
+ Graphical parameters. Defaults to ``Gpar(col="grey")``.
1537
+ vp : object or None
1538
+ Viewport.
1539
+ """
1540
+ if h is None:
1541
+ h = Unit([0.25, 0.50, 0.75], "npc")
1542
+ if not is_unit(h):
1543
+ h = Unit(h, default_units)
1544
+ if v is None:
1545
+ v = Unit([0.25, 0.50, 0.75], "npc")
1546
+ if not is_unit(v):
1547
+ v = Unit(v, default_units)
1548
+ if gp is None:
1549
+ gp = Gpar(col="grey")
1550
+
1551
+ if vp is not None:
1552
+ push_viewport(vp)
1553
+
1554
+ # Vertical lines
1555
+ grid_segments(
1556
+ x0=v, y0=Unit(0, "npc"),
1557
+ x1=v, y1=Unit(1, "npc"),
1558
+ gp=gp,
1559
+ )
1560
+ # Horizontal lines
1561
+ grid_segments(
1562
+ x0=Unit(0, "npc"), y0=h,
1563
+ x1=Unit(1, "npc"), y1=h,
1564
+ gp=gp,
1565
+ )
1566
+
1567
+ if vp is not None:
1568
+ pop_viewport()
1569
+
1570
+
1571
+ def grid_show_layout(
1572
+ layout: GridLayout,
1573
+ newpage: bool = True,
1574
+ vp_ex: float = 0.8,
1575
+ bg: str = "light grey",
1576
+ cell_border: str = "blue",
1577
+ cell_fill: str = "light blue",
1578
+ cell_label: bool = True,
1579
+ label_col: str = "blue",
1580
+ unit_col: str = "red",
1581
+ vp: Optional[Any] = None,
1582
+ ) -> Viewport:
1583
+ """Visualize a :class:`GridLayout`.
1584
+
1585
+ Draws a representation of the layout on the current device, showing
1586
+ cell boundaries, labels, and dimension annotations.
1587
+
1588
+ Parameters
1589
+ ----------
1590
+ layout : GridLayout
1591
+ The layout to visualize.
1592
+ newpage : bool
1593
+ If ``True``, start a new page before drawing.
1594
+ vp_ex : float
1595
+ Fraction of the page used for the viewport (0--1).
1596
+ bg : str
1597
+ Background colour.
1598
+ cell_border : str
1599
+ Cell border colour.
1600
+ cell_fill : str
1601
+ Cell fill colour.
1602
+ cell_label : bool
1603
+ If ``True``, label each cell with ``(row, col)``.
1604
+ label_col : str
1605
+ Colour for cell labels.
1606
+ unit_col : str
1607
+ Colour for dimension annotations.
1608
+ vp : object or None
1609
+ Viewport to push before drawing.
1610
+
1611
+ Returns
1612
+ -------
1613
+ Viewport
1614
+ The viewport used to represent the parent region.
1615
+ """
1616
+ if newpage:
1617
+ grid_newpage()
1618
+ if vp is not None:
1619
+ push_viewport(vp)
1620
+
1621
+ grid_rect(gp=Gpar(col=None, fill=bg))
1622
+
1623
+ vp_mid = Viewport(
1624
+ x=Unit(0.5, "npc"), y=Unit(0.5, "npc"),
1625
+ width=Unit(vp_ex, "npc"), height=Unit(vp_ex, "npc"),
1626
+ layout=layout,
1627
+ )
1628
+ push_viewport(vp_mid)
1629
+ grid_rect(gp=Gpar(fill="white"))
1630
+
1631
+ gp_red = Gpar(col=unit_col)
1632
+ nr = layout_nrow(layout)
1633
+ nc = layout_ncol(layout)
1634
+
1635
+ for i in range(1, nr + 1):
1636
+ for j in range(1, nc + 1):
1637
+ vp_inner = Viewport(layout_pos_row=i, layout_pos_col=j)
1638
+ push_viewport(vp_inner)
1639
+ grid_rect(gp=Gpar(col=cell_border, fill=cell_fill))
1640
+ if cell_label:
1641
+ grid_text(label=f"({i}, {j})", gp=Gpar(col=label_col))
1642
+ # Dimension annotations on the edges
1643
+ if j == 1:
1644
+ grid_text(
1645
+ label=str(layout_heights(layout)),
1646
+ gp=gp_red,
1647
+ just=["right", "centre"],
1648
+ x=Unit(-0.05, "inches"),
1649
+ y=Unit(0.5, "npc"),
1650
+ rot=0,
1651
+ )
1652
+ if i == nr:
1653
+ grid_text(
1654
+ label=str(layout_widths(layout)),
1655
+ gp=gp_red,
1656
+ just=["centre", "top"],
1657
+ x=Unit(0.5, "npc"),
1658
+ y=Unit(-0.05, "inches"),
1659
+ rot=0,
1660
+ )
1661
+ if j == nc:
1662
+ grid_text(
1663
+ label=str(layout_heights(layout)),
1664
+ gp=gp_red,
1665
+ just=["left", "centre"],
1666
+ x=Unit(1, "npc") + Unit(0.05, "inches"),
1667
+ y=Unit(0.5, "npc"),
1668
+ rot=0,
1669
+ )
1670
+ if i == 1:
1671
+ grid_text(
1672
+ label=str(layout_widths(layout)),
1673
+ gp=gp_red,
1674
+ just=["centre", "bottom"],
1675
+ x=Unit(0.5, "npc"),
1676
+ y=Unit(1, "npc") + Unit(0.05, "inches"),
1677
+ rot=0,
1678
+ )
1679
+ pop_viewport()
1680
+
1681
+ pop_viewport()
1682
+ if vp is not None:
1683
+ pop_viewport()
1684
+ return vp_mid
1685
+
1686
+
1687
+ def grid_show_viewport(
1688
+ v: Optional[Viewport] = None,
1689
+ parent_layout: Optional[GridLayout] = None,
1690
+ newpage: bool = True,
1691
+ vp_ex: float = 0.8,
1692
+ border_fill: str = "light grey",
1693
+ vp_col: str = "blue",
1694
+ vp_fill: str = "light blue",
1695
+ scale_col: str = "red",
1696
+ vp: Optional[Any] = None,
1697
+ recurse: bool = True,
1698
+ depth: int = 0,
1699
+ ) -> None:
1700
+ """Visualize a viewport (or viewport tree).
1701
+
1702
+ Draws a representation of the viewport on the current device, showing
1703
+ its position, size, and native scale.
1704
+
1705
+ Parameters
1706
+ ----------
1707
+ v : Viewport or None
1708
+ The viewport to visualize. If ``None``, uses the current viewport.
1709
+ parent_layout : GridLayout or None
1710
+ The parent viewport's layout (used when *v* has layout position).
1711
+ newpage : bool
1712
+ If ``True``, start a new page.
1713
+ vp_ex : float
1714
+ Fraction of the page used for the outer viewport.
1715
+ border_fill : str
1716
+ Fill colour for the outer border area.
1717
+ vp_col : str
1718
+ Border colour for the viewport rectangle.
1719
+ vp_fill : str
1720
+ Fill colour for the viewport rectangle.
1721
+ scale_col : str
1722
+ Colour for scale annotations.
1723
+ vp : object or None
1724
+ Viewport to push before drawing.
1725
+ recurse : bool
1726
+ If ``True``, recurse into child viewports (not yet implemented).
1727
+ depth : int
1728
+ Current recursion depth.
1729
+ """
1730
+ if v is None:
1731
+ v = Viewport()
1732
+
1733
+ # Check if viewport has layout position and parent layout
1734
+ has_pos = (getattr(v, "layout_pos_row", None) is not None or
1735
+ getattr(v, "layout_pos_col", None) is not None)
1736
+ if has_pos and parent_layout is not None:
1737
+ # Show within parent layout context
1738
+ if vp is not None:
1739
+ push_viewport(vp)
1740
+ vp_mid = grid_show_layout(
1741
+ parent_layout, vp_ex=vp_ex,
1742
+ cell_border="grey", cell_fill="white",
1743
+ cell_label=False, newpage=newpage,
1744
+ )
1745
+ push_viewport(vp_mid)
1746
+ push_viewport(v)
1747
+ gp_red = Gpar(col=scale_col)
1748
+ grid_rect(gp=Gpar(col="blue", fill="light blue"))
1749
+ xscale = getattr(v, "xscale", [0, 1])
1750
+ at = grid_pretty(xscale)
1751
+ if len(at) >= 2:
1752
+ grid_xaxis(at=[min(at), max(at)], gp=gp_red)
1753
+ yscale = getattr(v, "yscale", [0, 1])
1754
+ at = grid_pretty(yscale)
1755
+ if len(at) >= 2:
1756
+ grid_yaxis(at=[min(at), max(at)], gp=gp_red)
1757
+ pop_viewport(2)
1758
+ if vp is not None:
1759
+ pop_viewport()
1760
+ else:
1761
+ # Standard display
1762
+ if newpage:
1763
+ grid_newpage()
1764
+ if vp is not None:
1765
+ push_viewport(vp)
1766
+ grid_rect(gp=Gpar(col=None, fill=border_fill))
1767
+ vp_mid = Viewport(
1768
+ x=Unit(0.5, "npc"), y=Unit(0.5, "npc"),
1769
+ width=Unit(vp_ex, "npc"), height=Unit(vp_ex, "npc"),
1770
+ )
1771
+ push_viewport(vp_mid)
1772
+ grid_rect(gp=Gpar(fill="white"))
1773
+ push_viewport(v)
1774
+ grid_rect(gp=Gpar(col=vp_col, fill=vp_fill))
1775
+ gp_red = Gpar(col=scale_col)
1776
+ xscale = getattr(v, "xscale", [0, 1])
1777
+ at = grid_pretty(xscale)
1778
+ if len(at) >= 2:
1779
+ grid_xaxis(at=[min(at), max(at)], gp=gp_red)
1780
+ yscale = getattr(v, "yscale", [0, 1])
1781
+ at = grid_pretty(yscale)
1782
+ if len(at) >= 2:
1783
+ grid_yaxis(at=[min(at), max(at)], gp=gp_red)
1784
+ pop_viewport(2)
1785
+ if vp is not None:
1786
+ pop_viewport()
1787
+
1788
+
1789
+ def grid_abline(
1790
+ intercept: float = 0,
1791
+ slope: float = 1,
1792
+ gp: Optional[Gpar] = None,
1793
+ draw: bool = True,
1794
+ name: Optional[str] = None,
1795
+ vp: Optional[Any] = None,
1796
+ ) -> Grob:
1797
+ """Draw a line from the equation ``y = intercept + slope * x``.
1798
+
1799
+ The line is drawn across the full NPC range ``[0, 1]``, mapping the
1800
+ x-values 0 and 1 through the linear equation to obtain y-values.
1801
+
1802
+ Parameters
1803
+ ----------
1804
+ intercept : float
1805
+ Y-intercept of the line.
1806
+ slope : float
1807
+ Slope of the line.
1808
+ gp : Gpar or None
1809
+ Graphical parameters.
1810
+ draw : bool
1811
+ If ``True``, draw immediately.
1812
+ name : str or None
1813
+ Grob name.
1814
+ vp : object or None
1815
+ Viewport.
1816
+
1817
+ Returns
1818
+ -------
1819
+ Grob
1820
+ A lines grob representing the line.
1821
+ """
1822
+ x = [0, 1]
1823
+ y = [intercept + slope * xi for xi in x]
1824
+ g = lines_grob(
1825
+ x=Unit(x, "npc"),
1826
+ y=Unit(y, "npc"),
1827
+ gp=gp,
1828
+ name=name,
1829
+ vp=vp,
1830
+ )
1831
+ if draw:
1832
+ grid_draw(g)
1833
+ return g
1834
+
1835
+
1836
+ def grid_plot_and_legend(
1837
+ plot_expr: Optional[Grob] = None,
1838
+ legend_expr: Optional[Grob] = None,
1839
+ widths: Optional[Any] = None,
1840
+ heights: Optional[Any] = None,
1841
+ ) -> None:
1842
+ """Layout a plot and legend side by side.
1843
+
1844
+ This is a simple demonstration function that creates a frame with
1845
+ the plot on the left and the legend on the right.
1846
+
1847
+ Parameters
1848
+ ----------
1849
+ plot_expr : Grob or None
1850
+ A grob (or GTree) for the main plot area.
1851
+ legend_expr : Grob or None
1852
+ A grob (or GTree) for the legend.
1853
+ widths : Unit or None
1854
+ Column widths (unused in simple version).
1855
+ heights : Unit or None
1856
+ Row heights (unused in simple version).
1857
+ """
1858
+ grid_newpage()
1859
+ top_vp = Viewport(width=Unit(0.8, "npc"), height=Unit(0.8, "npc"))
1860
+ push_viewport(top_vp)
1861
+
1862
+ lf = frame_grob()
1863
+ if plot_expr is not None:
1864
+ lf = pack_grob(lf, plot_expr)
1865
+ if legend_expr is not None:
1866
+ lf = pack_grob(lf, legend_expr,
1867
+ height=Unit(1, "null"), side="right")
1868
+ grid_draw(lf)
1869
+
1870
+
1871
+ def layout_torture(
1872
+ n_row: int = 2,
1873
+ n_col: int = 2,
1874
+ ) -> None:
1875
+ """Stress-test the layout system with a simple grid of cells.
1876
+
1877
+ Creates a layout with *n_row* rows and *n_col* columns, populates
1878
+ each cell with a labelled rectangle, and draws the result.
1879
+
1880
+ Parameters
1881
+ ----------
1882
+ n_row : int
1883
+ Number of rows.
1884
+ n_col : int
1885
+ Number of columns.
1886
+ """
1887
+ grid_newpage()
1888
+ lay = GridLayout(nrow=n_row, ncol=n_col)
1889
+ top_vp = Viewport(layout=lay)
1890
+ push_viewport(top_vp)
1891
+
1892
+ for i in range(1, n_row + 1):
1893
+ for j in range(1, n_col + 1):
1894
+ cell_vp = Viewport(layout_pos_row=i, layout_pos_col=j)
1895
+ push_viewport(cell_vp)
1896
+ grid_rect(gp=Gpar(col="blue", fill="light blue"))
1897
+ grid_text(label=f"({i}, {j})")
1898
+ pop_viewport()
1899
+
1900
+ pop_viewport()
1901
+
1902
+
1903
+ # ---------------------------------------------------------------------------
1904
+ # Panel / strip / multipanel stubs (highlevel.R)
1905
+ # ---------------------------------------------------------------------------
1906
+
1907
+
1908
+ def grid_strip(
1909
+ label: str = "whatever",
1910
+ range_full: Sequence[float] = (0, 1),
1911
+ range_thumb: Sequence[float] = (0.3, 0.6),
1912
+ fill: str = "#FFBF00",
1913
+ thumb: str = "#FF8000",
1914
+ vp: Optional[Any] = None,
1915
+ ) -> None:
1916
+ """Draw a strip indicator (simple stub).
1917
+
1918
+ Parameters
1919
+ ----------
1920
+ label : str
1921
+ Label text.
1922
+ range_full : sequence of float
1923
+ Full range.
1924
+ range_thumb : sequence of float
1925
+ Thumb (selected) range.
1926
+ fill : str
1927
+ Background fill colour.
1928
+ thumb : str
1929
+ Thumb fill colour.
1930
+ vp : object or None
1931
+ Viewport.
1932
+ """
1933
+ diff_full = range_full[1] - range_full[0]
1934
+ diff_thumb = range_thumb[1] - range_thumb[0]
1935
+ if vp is not None:
1936
+ push_viewport(vp)
1937
+ grid_rect(gp=Gpar(col=None, fill=fill))
1938
+ grid_rect(
1939
+ x=Unit((range_thumb[0] - range_full[0]) / diff_full, "npc"),
1940
+ y=Unit(0, "npc"),
1941
+ width=Unit(diff_thumb / diff_full, "npc"),
1942
+ height=Unit(1, "npc"),
1943
+ just=["left", "bottom"],
1944
+ gp=Gpar(col=None, fill=thumb),
1945
+ )
1946
+ grid_text(label=label)
1947
+ if vp is not None:
1948
+ pop_viewport()
1949
+
1950
+
1951
+ def grid_panel(
1952
+ x: Optional[Sequence[float]] = None,
1953
+ y: Optional[Sequence[float]] = None,
1954
+ zrange: Sequence[float] = (0, 1),
1955
+ zbin: Optional[Sequence[float]] = None,
1956
+ xscale: Optional[Sequence[float]] = None,
1957
+ yscale: Optional[Sequence[float]] = None,
1958
+ axis_left: bool = True,
1959
+ axis_left_label: bool = True,
1960
+ axis_right: bool = False,
1961
+ axis_right_label: bool = True,
1962
+ axis_bottom: bool = True,
1963
+ axis_bottom_label: bool = True,
1964
+ axis_top: bool = False,
1965
+ axis_top_label: bool = True,
1966
+ vp: Optional[Any] = None,
1967
+ ) -> Dict[str, Viewport]:
1968
+ """Draw a panel with optional axes and strip (simple stub).
1969
+
1970
+ Parameters
1971
+ ----------
1972
+ x : sequence of float or None
1973
+ X data values.
1974
+ y : sequence of float or None
1975
+ Y data values.
1976
+ zrange : sequence of float
1977
+ Full z-range for the strip.
1978
+ zbin : sequence of float or None
1979
+ Z-bin for the strip.
1980
+ xscale : sequence of float or None
1981
+ X-axis scale.
1982
+ yscale : sequence of float or None
1983
+ Y-axis scale.
1984
+ axis_left : bool
1985
+ Show left axis.
1986
+ axis_left_label : bool
1987
+ Show left axis labels.
1988
+ axis_right : bool
1989
+ Show right axis.
1990
+ axis_right_label : bool
1991
+ Show right axis labels.
1992
+ axis_bottom : bool
1993
+ Show bottom axis.
1994
+ axis_bottom_label : bool
1995
+ Show bottom axis labels.
1996
+ axis_top : bool
1997
+ Show top axis.
1998
+ axis_top_label : bool
1999
+ Show top axis labels.
2000
+ vp : object or None
2001
+ Viewport.
2002
+
2003
+ Returns
2004
+ -------
2005
+ dict
2006
+ A dictionary with ``"strip_vp"`` and ``"plot_vp"`` keys.
2007
+ """
2008
+ if x is None:
2009
+ x = list(np.random.uniform(size=10))
2010
+ if y is None:
2011
+ y = list(np.random.uniform(size=10))
2012
+ if zbin is None:
2013
+ zbin = list(np.random.uniform(size=2))
2014
+ if xscale is None:
2015
+ xscale = list(_extend_range(x))
2016
+ if yscale is None:
2017
+ yscale = list(_extend_range(y))
2018
+
2019
+ if vp is not None:
2020
+ push_viewport(vp)
2021
+
2022
+ temp_vp = Viewport(
2023
+ layout=GridLayout(
2024
+ nrow=2, ncol=1,
2025
+ heights=Unit([1, 1], ["lines", "null"]),
2026
+ )
2027
+ )
2028
+ push_viewport(temp_vp)
2029
+
2030
+ strip_vp = Viewport(layout_pos_row=1, layout_pos_col=1, xscale=xscale)
2031
+ push_viewport(strip_vp)
2032
+ grid_strip(range_full=zrange, range_thumb=zbin)
2033
+ grid_rect()
2034
+ if axis_top:
2035
+ grid_xaxis(main=False, label=axis_top_label)
2036
+ pop_viewport()
2037
+
2038
+ plot_vp = Viewport(
2039
+ layout_pos_row=2, layout_pos_col=1,
2040
+ xscale=xscale, yscale=yscale,
2041
+ )
2042
+ push_viewport(plot_vp)
2043
+ grid_grill()
2044
+ grid_points(x=x, y=y, gp=Gpar(col="blue"))
2045
+ grid_rect()
2046
+ if axis_left:
2047
+ grid_yaxis(label=axis_left_label)
2048
+ if axis_right:
2049
+ grid_yaxis(main=False, label=axis_right_label)
2050
+ if axis_bottom:
2051
+ grid_xaxis(label=axis_bottom_label)
2052
+ pop_viewport(2)
2053
+
2054
+ if vp is not None:
2055
+ pop_viewport()
2056
+
2057
+ return {"strip_vp": strip_vp, "plot_vp": plot_vp}
2058
+
2059
+
2060
+ def grid_multipanel(
2061
+ x: Optional[Sequence[float]] = None,
2062
+ y: Optional[Sequence[float]] = None,
2063
+ z: Optional[Sequence[float]] = None,
2064
+ nplots: int = 9,
2065
+ nrow: Optional[int] = None,
2066
+ ncol: Optional[int] = None,
2067
+ newpage: bool = True,
2068
+ vp: Optional[Any] = None,
2069
+ ) -> None:
2070
+ """Draw a multi-panel layout (simple stub).
2071
+
2072
+ Parameters
2073
+ ----------
2074
+ x : sequence of float or None
2075
+ X data values.
2076
+ y : sequence of float or None
2077
+ Y data values.
2078
+ z : sequence of float or None
2079
+ Z data values used to split into panels.
2080
+ nplots : int
2081
+ Number of panels.
2082
+ nrow : int or None
2083
+ Number of rows (computed from *nplots* if ``None``).
2084
+ ncol : int or None
2085
+ Number of columns (computed from *nplots* if ``None``).
2086
+ newpage : bool
2087
+ If ``True``, start a new page.
2088
+ vp : object or None
2089
+ Viewport.
2090
+ """
2091
+ if x is None:
2092
+ x = list(np.random.uniform(size=90))
2093
+ if y is None:
2094
+ y = list(np.random.uniform(size=90))
2095
+ if z is None:
2096
+ z = list(np.random.uniform(size=90))
2097
+
2098
+ if nplots < 1:
2099
+ raise ValueError("'nplots' must be >= 1")
2100
+
2101
+ # Smart defaults for nrow/ncol
2102
+ if nrow is None or ncol is None:
2103
+ ncol_auto = max(1, math.ceil(math.sqrt(nplots)))
2104
+ nrow_auto = math.ceil(nplots / ncol_auto)
2105
+ if nrow is None:
2106
+ nrow = nrow_auto
2107
+ if ncol is None:
2108
+ ncol = ncol_auto
2109
+
2110
+ if newpage:
2111
+ grid_newpage()
2112
+ if vp is not None:
2113
+ push_viewport(vp)
2114
+
2115
+ temp_vp = Viewport(layout=GridLayout(nrow=nrow, ncol=ncol))
2116
+ push_viewport(temp_vp)
2117
+
2118
+ xscale = list(_extend_range(x))
2119
+ yscale = list(_extend_range(y))
2120
+ breaks = list(np.linspace(min(z), max(z), nplots + 1))
2121
+
2122
+ for i in range(nplots):
2123
+ col_idx = i % ncol + 1
2124
+ row_idx = i // ncol + 1
2125
+ panel_vp = Viewport(layout_pos_row=row_idx, layout_pos_col=col_idx)
2126
+
2127
+ # Subset data
2128
+ mask = [(zv >= breaks[i] and zv <= breaks[i + 1]) for zv in z]
2129
+ panelx = [xv for xv, m in zip(x, mask) if m]
2130
+ panely = [yv for yv, m in zip(y, mask) if m]
2131
+
2132
+ if len(panelx) == 0:
2133
+ panelx = [0.5]
2134
+ panely = [0.5]
2135
+
2136
+ grid_panel(
2137
+ x=panelx, y=panely,
2138
+ zrange=[min(z), max(z)],
2139
+ zbin=[breaks[i], breaks[i + 1]],
2140
+ xscale=xscale, yscale=yscale,
2141
+ axis_left=(col_idx == 1),
2142
+ axis_right=(col_idx == ncol or i == nplots - 1),
2143
+ axis_bottom=(row_idx == nrow),
2144
+ axis_top=(row_idx == 1),
2145
+ axis_left_label=_is_even(row_idx),
2146
+ axis_right_label=_is_odd(row_idx),
2147
+ axis_bottom_label=_is_even(col_idx),
2148
+ axis_top_label=_is_odd(col_idx),
2149
+ vp=panel_vp,
2150
+ )
2151
+
2152
+ pop_viewport()
2153
+ if vp is not None:
2154
+ pop_viewport()
2155
+
2156
+
2157
+ # ---------------------------------------------------------------------------
2158
+ # Top-level viewport helper
2159
+ # ---------------------------------------------------------------------------
2160
+
2161
+
2162
+ def grid_top_level_vp() -> Viewport:
2163
+ """Return a top-level viewport suitable for a standard multi-panel layout.
2164
+
2165
+ This viewport occupies 80% of the device centred in the page, matching
2166
+ the common R pattern for demonstration plots.
2167
+
2168
+ Returns
2169
+ -------
2170
+ Viewport
2171
+ A viewport with width and height of ``0.8 npc``.
2172
+ """
2173
+ return Viewport(
2174
+ width=Unit(0.8, "npc"),
2175
+ height=Unit(0.8, "npc"),
2176
+ )