ggplot2-python 4.0.2.9000__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.
Files changed (54) hide show
  1. ggplot2_py/__init__.py +852 -0
  2. ggplot2_py/_compat.py +475 -0
  3. ggplot2_py/_plugins.py +129 -0
  4. ggplot2_py/_utils.py +544 -0
  5. ggplot2_py/aes.py +586 -0
  6. ggplot2_py/annotation.py +540 -0
  7. ggplot2_py/coord.py +2108 -0
  8. ggplot2_py/coords/__init__.py +49 -0
  9. ggplot2_py/datasets.py +265 -0
  10. ggplot2_py/draw_key.py +454 -0
  11. ggplot2_py/facet.py +1456 -0
  12. ggplot2_py/fortify.py +95 -0
  13. ggplot2_py/geom.py +4516 -0
  14. ggplot2_py/geoms/__init__.py +12 -0
  15. ggplot2_py/ggproto.py +279 -0
  16. ggplot2_py/guide.py +2925 -0
  17. ggplot2_py/guide_axis.py +615 -0
  18. ggplot2_py/guide_colourbar.py +657 -0
  19. ggplot2_py/guide_legend.py +1061 -0
  20. ggplot2_py/guides/__init__.py +8 -0
  21. ggplot2_py/labeller.py +296 -0
  22. ggplot2_py/labels.py +309 -0
  23. ggplot2_py/layer.py +954 -0
  24. ggplot2_py/layout.py +754 -0
  25. ggplot2_py/limits.py +314 -0
  26. ggplot2_py/plot.py +1401 -0
  27. ggplot2_py/plot_render.py +866 -0
  28. ggplot2_py/position.py +1269 -0
  29. ggplot2_py/protocols.py +171 -0
  30. ggplot2_py/py.typed +0 -0
  31. ggplot2_py/qplot.py +233 -0
  32. ggplot2_py/resources/diamonds.csv +53941 -0
  33. ggplot2_py/resources/economics.csv +575 -0
  34. ggplot2_py/resources/economics_long.csv +2871 -0
  35. ggplot2_py/resources/faithfuld.csv +5626 -0
  36. ggplot2_py/resources/luv_colours.csv +658 -0
  37. ggplot2_py/resources/midwest.csv +438 -0
  38. ggplot2_py/resources/mpg.csv +235 -0
  39. ggplot2_py/resources/msleep.csv +84 -0
  40. ggplot2_py/resources/presidential.csv +13 -0
  41. ggplot2_py/resources/seals.csv +1156 -0
  42. ggplot2_py/resources/txhousing.csv +8603 -0
  43. ggplot2_py/save.py +316 -0
  44. ggplot2_py/scale.py +2727 -0
  45. ggplot2_py/scales/__init__.py +4252 -0
  46. ggplot2_py/stat.py +6071 -0
  47. ggplot2_py/stats/__init__.py +9 -0
  48. ggplot2_py/theme.py +490 -0
  49. ggplot2_py/theme_defaults.py +1350 -0
  50. ggplot2_py/theme_elements.py +2052 -0
  51. ggplot2_python-4.0.2.9000.dist-info/METADATA +179 -0
  52. ggplot2_python-4.0.2.9000.dist-info/RECORD +54 -0
  53. ggplot2_python-4.0.2.9000.dist-info/WHEEL +4 -0
  54. ggplot2_python-4.0.2.9000.dist-info/licenses/LICENSE +3 -0
ggplot2_py/guide.py ADDED
@@ -0,0 +1,2925 @@
1
+ """
2
+ Guide system for ggplot2_py.
3
+
4
+ Ports the R guide infrastructure (``guide-.R``, ``guide-legend.R``,
5
+ ``guide-colorbar.R``, ``guide-axis.R``, ``guide-none.R``, ``guide-bins.R``,
6
+ ``guide-colorsteps.R``, ``guide-custom.R``, ``guide-axis-logticks.R``,
7
+ ``guide-axis-stack.R``, ``guide-axis-theta.R``, ``guide-old.R``, and
8
+ ``guides-.R``) into a unified Python module.
9
+
10
+ The module defines:
11
+
12
+ * **Guide** -- base GGProto class for all guides.
13
+ * Concrete guide classes (``GuideLegend``, ``GuideColourbar``, etc.).
14
+ * Constructor functions (``guide_legend()``, ``guide_colourbar()``, etc.).
15
+ * The **Guides** container and the ``guides()`` helper.
16
+ * Legacy S3-style shims (``guide_train``, ``guide_merge``, etc.).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import warnings
23
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
24
+
25
+ import numpy as np
26
+ import pandas as pd
27
+
28
+ from ggplot2_py.ggproto import GGProto, ggproto, ggproto_parent, is_ggproto
29
+ from ggplot2_py._compat import (
30
+ Waiver,
31
+ cli_abort,
32
+ cli_warn,
33
+ is_waiver,
34
+ waiver,
35
+ )
36
+ from ggplot2_py._utils import compact, modify_list, snake_class
37
+ from ggplot2_py.aes import standardise_aes_names, rename_aes
38
+
39
+ __all__ = [
40
+ # Classes
41
+ "Guide",
42
+ "GuideAxis",
43
+ "GuideAxisLogticks",
44
+ "GuideAxisStack",
45
+ "GuideAxisTheta",
46
+ "GuideBins",
47
+ "GuideColourbar",
48
+ "GuideColoursteps",
49
+ "GuideCustom",
50
+ "GuideLegend",
51
+ "GuideNone",
52
+ "GuideOld",
53
+ # Constructors
54
+ "guide_axis",
55
+ "guide_axis_logticks",
56
+ "guide_axis_stack",
57
+ "guide_axis_theta",
58
+ "guide_bins",
59
+ "guide_colourbar",
60
+ "guide_colorbar",
61
+ "guide_coloursteps",
62
+ "guide_colorsteps",
63
+ "guide_custom",
64
+ "guide_legend",
65
+ "guide_none",
66
+ # Guides container
67
+ "guides",
68
+ "Guides",
69
+ # Helpers
70
+ "new_guide",
71
+ "old_guide",
72
+ "guide_gengrob",
73
+ "guide_geom",
74
+ "guide_merge",
75
+ "guide_train",
76
+ "guide_transform",
77
+ "is_guide",
78
+ "is_guides",
79
+ ]
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Positional constants
84
+ # ---------------------------------------------------------------------------
85
+
86
+ _TRBL: List[str] = ["top", "right", "bottom", "left"]
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Utility helpers
91
+ # ---------------------------------------------------------------------------
92
+
93
+ def _hash_object(obj: Any) -> str:
94
+ """Return a deterministic hash string for *obj*.
95
+
96
+ Parameters
97
+ ----------
98
+ obj : Any
99
+ The object to hash. Converted to ``repr`` then hashed with MD5.
100
+
101
+ Returns
102
+ -------
103
+ str
104
+ Hexadecimal hash digest.
105
+ """
106
+ return hashlib.md5(repr(obj).encode("utf-8")).hexdigest()
107
+
108
+
109
+ def _defaults(target: dict, defaults: dict) -> dict:
110
+ """Return a new dict with *defaults* filled in for missing keys.
111
+
112
+ Parameters
113
+ ----------
114
+ target : dict
115
+ Primary values.
116
+ defaults : dict
117
+ Fall-back values.
118
+
119
+ Returns
120
+ -------
121
+ dict
122
+ Merged dictionary.
123
+ """
124
+ out = dict(defaults)
125
+ out.update(target)
126
+ return out
127
+
128
+
129
+ def _validate_guide(guide: Any) -> Any:
130
+ """Ensure *guide* is a Guide class/instance.
131
+
132
+ Parameters
133
+ ----------
134
+ guide : str or Guide
135
+ Either a guide shorthand name (e.g. ``"legend"``) or a Guide
136
+ class / instance.
137
+
138
+ Returns
139
+ -------
140
+ Guide
141
+ A validated guide object.
142
+
143
+ Raises
144
+ ------
145
+ ValueError
146
+ If *guide* cannot be resolved.
147
+ """
148
+ if isinstance(guide, str):
149
+ guide = _resolve_guide_name(guide)
150
+ if isinstance(guide, type) and issubclass(guide, GGProto):
151
+ # It is a class; instantiate with default params
152
+ return guide()
153
+ if isinstance(guide, GGProto):
154
+ return guide
155
+ cli_abort(f"Cannot resolve guide: {guide!r}")
156
+
157
+
158
+ def _resolve_guide_name(name: str) -> type:
159
+ """Map a short string name to a Guide class.
160
+
161
+ Parameters
162
+ ----------
163
+ name : str
164
+ Short name, e.g. ``"legend"``, ``"colourbar"``, ``"none"``.
165
+
166
+ Returns
167
+ -------
168
+ type
169
+ The corresponding Guide class.
170
+ """
171
+ _REGISTRY: Dict[str, type] = {
172
+ "none": GuideNone,
173
+ "legend": GuideLegend,
174
+ "colourbar": GuideColourbar,
175
+ "colorbar": GuideColourbar,
176
+ "coloursteps": GuideColoursteps,
177
+ "colorsteps": GuideColoursteps,
178
+ "bins": GuideBins,
179
+ "axis": GuideAxis,
180
+ "axis_logticks": GuideAxisLogticks,
181
+ "axis_theta": GuideAxisTheta,
182
+ "axis_stack": GuideAxisStack,
183
+ "custom": GuideCustom,
184
+ }
185
+ key = name.lower().replace("-", "_")
186
+ cls = _REGISTRY.get(key)
187
+ if cls is None:
188
+ cli_abort(f"Unknown guide type: {name!r}")
189
+ return cls
190
+
191
+
192
+ # ============================================================================
193
+ # Guide base class
194
+ # ============================================================================
195
+
196
+ class Guide(GGProto):
197
+ """Base class for all ggplot2 guides.
198
+
199
+ A ``Guide`` is responsible for rendering the visual representation of a
200
+ scale -- axis tick marks, legends, colour bars, and so on.
201
+
202
+ Attributes
203
+ ----------
204
+ params : dict
205
+ Default parameters. Subclasses extend this dict.
206
+ elements : dict
207
+ Theme element names used by this guide.
208
+ hashables : list[str]
209
+ Parameter keys used to compute a deduplication hash.
210
+ available_aes : list[str]
211
+ Aesthetics that this guide can represent.
212
+ """
213
+
214
+ _class_name: str = "Guide"
215
+
216
+ # -- Fields --------------------------------------------------------------
217
+
218
+ params: Dict[str, Any] = {
219
+ "title": waiver(),
220
+ "theme": None,
221
+ "name": "",
222
+ "position": waiver(),
223
+ "direction": None,
224
+ "order": 0,
225
+ "hash": "",
226
+ }
227
+
228
+ available_aes: List[str] = []
229
+
230
+ elements: Dict[str, str] = {}
231
+
232
+ hashables: List[str] = ["title", "name"]
233
+
234
+ # -- Key extraction ------------------------------------------------------
235
+
236
+ @staticmethod
237
+ def extract_key(
238
+ scale: Any,
239
+ aesthetic: str,
240
+ **kwargs: Any,
241
+ ) -> Optional[pd.DataFrame]:
242
+ """Extract key (break positions / labels) from a scale.
243
+
244
+ Parameters
245
+ ----------
246
+ scale : Scale
247
+ The scale from which to extract breaks.
248
+ aesthetic : str
249
+ Name of the aesthetic this guide represents.
250
+ **kwargs : Any
251
+ Additional arguments forwarded by subclasses.
252
+
253
+ Returns
254
+ -------
255
+ pd.DataFrame or None
256
+ A DataFrame with columns for the aesthetic, ``.value``, and
257
+ ``.label``; or ``None`` if the scale has no breaks.
258
+ """
259
+ breaks = getattr(scale, "get_breaks", lambda: None)()
260
+ if breaks is None:
261
+ return None
262
+ mapped = getattr(scale, "map", lambda x: x)(breaks)
263
+ labels = getattr(scale, "get_labels", lambda x: x)(breaks)
264
+
265
+ key = pd.DataFrame({
266
+ aesthetic: mapped,
267
+ ".value": breaks,
268
+ ".label": labels if labels is not None else [str(b) for b in breaks],
269
+ })
270
+ return key
271
+
272
+ @staticmethod
273
+ def extract_decor(
274
+ scale: Any,
275
+ aesthetic: str,
276
+ **kwargs: Any,
277
+ ) -> Optional[pd.DataFrame]:
278
+ """Extract decoration data from a scale.
279
+
280
+ Parameters
281
+ ----------
282
+ scale : Scale
283
+ The scale.
284
+ aesthetic : str
285
+ Aesthetic name.
286
+ **kwargs : Any
287
+ Extra arguments.
288
+
289
+ Returns
290
+ -------
291
+ pd.DataFrame or None
292
+ Decoration data or ``None``.
293
+ """
294
+ return None
295
+
296
+ @staticmethod
297
+ def extract_params(
298
+ scale: Any,
299
+ params: Dict[str, Any],
300
+ **kwargs: Any,
301
+ ) -> Dict[str, Any]:
302
+ """Post-process guide parameters after extraction.
303
+
304
+ Parameters
305
+ ----------
306
+ scale : Scale
307
+ The source scale.
308
+ params : dict
309
+ Current guide parameters.
310
+ **kwargs : Any
311
+ Additional arguments.
312
+
313
+ Returns
314
+ -------
315
+ dict
316
+ Possibly-modified parameters.
317
+ """
318
+ title = kwargs.get("title", waiver())
319
+ scale_name = getattr(scale, "name", None)
320
+ if is_waiver(params.get("title")):
321
+ if not is_waiver(title):
322
+ params["title"] = title
323
+ elif scale_name is not None:
324
+ params["title"] = scale_name
325
+ return params
326
+
327
+ # -- Training / transform ------------------------------------------------
328
+
329
+ def train(
330
+ self,
331
+ params: Optional[Dict[str, Any]] = None,
332
+ scale: Any = None,
333
+ aesthetic: Optional[str] = None,
334
+ **kwargs: Any,
335
+ ) -> Optional[Dict[str, Any]]:
336
+ """Train the guide on a scale.
337
+
338
+ Parameters
339
+ ----------
340
+ params : dict, optional
341
+ Guide parameters.
342
+ scale : Scale, optional
343
+ The scale to train on.
344
+ aesthetic : str, optional
345
+ Aesthetic name.
346
+ **kwargs : Any
347
+ Extra arguments (e.g. ``title``).
348
+
349
+ Returns
350
+ -------
351
+ dict or None
352
+ Updated parameters, or ``None`` to drop this guide.
353
+ """
354
+ if params is None:
355
+ params = dict(self.params)
356
+ if scale is None:
357
+ return params
358
+
359
+ params["aesthetic"] = aesthetic or ""
360
+
361
+ # Extract key — mirrors R's inject(self$extract_key(scale, !!!params))
362
+ safe = {k: v for k, v in params.items() if k not in ("key", "decor")}
363
+ try:
364
+ key = self.extract_key(scale, **safe)
365
+ except TypeError:
366
+ # Fallback: pass only aesthetic
367
+ key = self.extract_key(scale, aesthetic=aesthetic)
368
+ if key is not None and hasattr(key, "empty") and key.empty:
369
+ return None
370
+ params["key"] = key
371
+
372
+ # Extract decor
373
+ try:
374
+ params["decor"] = self.extract_decor(scale, aesthetic=aesthetic)
375
+ except Exception:
376
+ params["decor"] = None
377
+
378
+ # Post-process
379
+ params = self.extract_params(scale, params)
380
+
381
+ # Compute hash
382
+ hash_vals = []
383
+ for h in self.hashables:
384
+ if h in params:
385
+ hash_vals.append(params[h])
386
+ elif isinstance(params.get("key"), pd.DataFrame) and h.startswith("key."):
387
+ col = h.split(".", 1)[1]
388
+ if col in params["key"].columns:
389
+ hash_vals.append(list(params["key"][col]))
390
+ params["hash"] = _hash_object(hash_vals)
391
+
392
+ return params
393
+
394
+ @staticmethod
395
+ def transform(
396
+ params: Dict[str, Any],
397
+ coord: Any,
398
+ panel_params: Any,
399
+ ) -> Dict[str, Any]:
400
+ """Transform guide data through coordinate system.
401
+
402
+ Parameters
403
+ ----------
404
+ params : dict
405
+ Guide parameters including ``key`` and ``decor``.
406
+ coord : Coord
407
+ Coordinate system.
408
+ panel_params : object
409
+ Panel parameters from the coordinate system.
410
+
411
+ Returns
412
+ -------
413
+ dict
414
+ Parameters with transformed ``key`` / ``decor``.
415
+ """
416
+ key = params.get("key")
417
+ if key is not None and hasattr(coord, "transform") and not key.empty:
418
+ params["key"] = coord.transform(key, panel_params)
419
+ return params
420
+
421
+ def get_layer_key(
422
+ self,
423
+ params: Dict[str, Any],
424
+ layers: List[Any],
425
+ data: Optional[List[Any]] = None,
426
+ ) -> Dict[str, Any]:
427
+ """Map layer key information into the guide parameters.
428
+
429
+ Parameters
430
+ ----------
431
+ params : dict
432
+ Guide parameters.
433
+ layers : list
434
+ Plot layers.
435
+ data : list, optional
436
+ Layer data.
437
+
438
+ Returns
439
+ -------
440
+ dict
441
+ Updated parameters.
442
+ """
443
+ return params
444
+
445
+ def process_layers(
446
+ self,
447
+ params: Dict[str, Any],
448
+ layers: List[Any],
449
+ data: Optional[List[Any]] = None,
450
+ theme: Any = None,
451
+ ) -> Optional[Dict[str, Any]]:
452
+ """Process layer information to generate geom info.
453
+
454
+ Parameters
455
+ ----------
456
+ params : dict
457
+ Guide parameters.
458
+ layers : list
459
+ Plot layers.
460
+ data : list, optional
461
+ Layer data.
462
+ theme : Theme, optional
463
+ Plot theme.
464
+
465
+ Returns
466
+ -------
467
+ dict or None
468
+ Updated parameters or ``None`` if guide should be dropped.
469
+ """
470
+ return self.get_layer_key(params, layers, data)
471
+
472
+ # -- Setup / override ----------------------------------------------------
473
+
474
+ @staticmethod
475
+ def setup_params(params: Dict[str, Any]) -> Dict[str, Any]:
476
+ """Validate and set up parameters before drawing.
477
+
478
+ Parameters
479
+ ----------
480
+ params : dict
481
+ Guide parameters.
482
+
483
+ Returns
484
+ -------
485
+ dict
486
+ Validated parameters.
487
+ """
488
+ return params
489
+
490
+ @staticmethod
491
+ def override_elements(
492
+ params: Dict[str, Any],
493
+ elements: Dict[str, Any],
494
+ theme: Any,
495
+ ) -> Dict[str, Any]:
496
+ """Resolve theme elements for this guide.
497
+
498
+ Parameters
499
+ ----------
500
+ params : dict
501
+ Guide parameters.
502
+ elements : dict
503
+ Element name -> theme element name mapping.
504
+ theme : Theme
505
+ The plot theme.
506
+
507
+ Returns
508
+ -------
509
+ dict
510
+ Resolved element objects.
511
+ """
512
+ return elements
513
+
514
+ def setup_elements(
515
+ self,
516
+ params: Dict[str, Any],
517
+ elements: Optional[Dict[str, str]] = None,
518
+ theme: Any = None,
519
+ ) -> Dict[str, Any]:
520
+ """Set up theme elements used by this guide.
521
+
522
+ Parameters
523
+ ----------
524
+ params : dict
525
+ Guide parameters.
526
+ elements : dict, optional
527
+ Element specifications. Falls back to ``self.elements``.
528
+ theme : Theme, optional
529
+ Plot theme.
530
+
531
+ Returns
532
+ -------
533
+ dict
534
+ Resolved elements.
535
+ """
536
+ if elements is None:
537
+ elements = dict(self.elements)
538
+ return self.override_elements(params, elements, theme)
539
+
540
+ # -- Build methods -------------------------------------------------------
541
+
542
+ @staticmethod
543
+ def build_title(
544
+ label: Any,
545
+ elements: Dict[str, Any],
546
+ params: Dict[str, Any],
547
+ ) -> Any:
548
+ """Build the guide title grob.
549
+
550
+ Parameters
551
+ ----------
552
+ label : str or None
553
+ Title text.
554
+ elements : dict
555
+ Resolved theme elements.
556
+ params : dict
557
+ Guide parameters.
558
+
559
+ Returns
560
+ -------
561
+ grob
562
+ A title grob or ``None``.
563
+ """
564
+ return None
565
+
566
+ @staticmethod
567
+ def build_labels(
568
+ key: pd.DataFrame,
569
+ elements: Dict[str, Any],
570
+ params: Dict[str, Any],
571
+ ) -> Any:
572
+ """Build label grobs from the key.
573
+
574
+ Parameters
575
+ ----------
576
+ key : pd.DataFrame
577
+ The guide key.
578
+ elements : dict
579
+ Resolved theme elements.
580
+ params : dict
581
+ Guide parameters.
582
+
583
+ Returns
584
+ -------
585
+ grob or list of grobs
586
+ Label grobs.
587
+ """
588
+ return None
589
+
590
+ @staticmethod
591
+ def build_decor(
592
+ decor: Any,
593
+ grobs: Any,
594
+ elements: Dict[str, Any],
595
+ params: Dict[str, Any],
596
+ ) -> Any:
597
+ """Build decoration grobs.
598
+
599
+ Parameters
600
+ ----------
601
+ decor : pd.DataFrame or None
602
+ Decoration data.
603
+ grobs : dict
604
+ Previously built grobs.
605
+ elements : dict
606
+ Resolved theme elements.
607
+ params : dict
608
+ Guide parameters.
609
+
610
+ Returns
611
+ -------
612
+ grob or list of grobs
613
+ Decoration grobs.
614
+ """
615
+ return None
616
+
617
+ @staticmethod
618
+ def build_ticks(
619
+ key: pd.DataFrame,
620
+ elements: Dict[str, Any],
621
+ params: Dict[str, Any],
622
+ ) -> Any:
623
+ """Build tick mark grobs.
624
+
625
+ Parameters
626
+ ----------
627
+ key : pd.DataFrame
628
+ The guide key.
629
+ elements : dict
630
+ Resolved theme elements.
631
+ params : dict
632
+ Guide parameters.
633
+
634
+ Returns
635
+ -------
636
+ grob
637
+ Tick mark grobs.
638
+ """
639
+ return None
640
+
641
+ @staticmethod
642
+ def measure_grobs(
643
+ grobs: Dict[str, Any],
644
+ params: Dict[str, Any],
645
+ elements: Dict[str, Any],
646
+ ) -> Dict[str, Any]:
647
+ """Measure built grobs for layout.
648
+
649
+ Parameters
650
+ ----------
651
+ grobs : dict
652
+ Named dictionary of grobs.
653
+ params : dict
654
+ Guide parameters.
655
+ elements : dict
656
+ Resolved elements.
657
+
658
+ Returns
659
+ -------
660
+ dict
661
+ Dictionary with ``width`` and ``height`` keys.
662
+ """
663
+ return {"width": None, "height": None}
664
+
665
+ @staticmethod
666
+ def arrange_layout(
667
+ key: pd.DataFrame,
668
+ sizes: Dict[str, Any],
669
+ params: Dict[str, Any],
670
+ elements: Dict[str, Any],
671
+ ) -> Dict[str, Any]:
672
+ """Compute the layout specification.
673
+
674
+ Parameters
675
+ ----------
676
+ key : pd.DataFrame
677
+ The guide key.
678
+ sizes : dict
679
+ Size measurements from :meth:`measure_grobs`.
680
+ params : dict
681
+ Guide parameters.
682
+ elements : dict
683
+ Resolved elements.
684
+
685
+ Returns
686
+ -------
687
+ dict
688
+ Layout specification.
689
+ """
690
+ return {}
691
+
692
+ @staticmethod
693
+ def assemble_drawing(
694
+ grobs: Dict[str, Any],
695
+ layout: Dict[str, Any],
696
+ sizes: Dict[str, Any],
697
+ params: Dict[str, Any],
698
+ elements: Dict[str, Any],
699
+ ) -> Any:
700
+ """Assemble the final guide drawing (gtable).
701
+
702
+ Parameters
703
+ ----------
704
+ grobs : dict
705
+ Named grobs.
706
+ layout : dict
707
+ Layout specification.
708
+ sizes : dict
709
+ Size measurements.
710
+ params : dict
711
+ Guide parameters.
712
+ elements : dict
713
+ Resolved elements.
714
+
715
+ Returns
716
+ -------
717
+ gtable or grob
718
+ The final assembled guide graphic.
719
+ """
720
+ return None
721
+
722
+ # -- Merge ---------------------------------------------------------------
723
+
724
+ def merge(
725
+ self,
726
+ params: Dict[str, Any],
727
+ new_guide: "Guide",
728
+ new_params: Dict[str, Any],
729
+ ) -> Dict[str, Any]:
730
+ """Merge another guide into this one.
731
+
732
+ Parameters
733
+ ----------
734
+ params : dict
735
+ This guide's parameters.
736
+ new_guide : Guide
737
+ The other guide.
738
+ new_params : dict
739
+ The other guide's parameters.
740
+
741
+ Returns
742
+ -------
743
+ dict
744
+ A dict with keys ``guide`` and ``params`` representing the
745
+ merged result.
746
+ """
747
+ new_key = new_params.get("key")
748
+ if new_key is not None and isinstance(new_key, pd.DataFrame):
749
+ key = params.get("key")
750
+ if key is not None and isinstance(key, pd.DataFrame):
751
+ # Merge keys by joining on shared columns
752
+ common = [c for c in key.columns if c in new_key.columns
753
+ and c.startswith(".")]
754
+ if common:
755
+ new_cols = [c for c in new_key.columns if c not in common]
756
+ if new_cols:
757
+ params["key"] = pd.merge(
758
+ key, new_key[common + new_cols],
759
+ on=common, how="left",
760
+ )
761
+ else:
762
+ # Just add new aesthetic columns
763
+ for col in new_key.columns:
764
+ if col not in key.columns:
765
+ params["key"][col] = new_key[col].values
766
+ return {"guide": self, "params": params}
767
+
768
+ # -- Draw ----------------------------------------------------------------
769
+
770
+ def draw(
771
+ self,
772
+ theme: Any = None,
773
+ position: Optional[str] = None,
774
+ direction: Optional[str] = None,
775
+ params: Optional[Dict[str, Any]] = None,
776
+ ) -> Any:
777
+ """Draw the guide.
778
+
779
+ Parameters
780
+ ----------
781
+ theme : Theme, optional
782
+ Plot theme.
783
+ position : str, optional
784
+ Position (``"top"``, ``"right"``, ``"bottom"``, ``"left"``,
785
+ ``"inside"``).
786
+ direction : str, optional
787
+ ``"horizontal"`` or ``"vertical"``.
788
+ params : dict, optional
789
+ Guide parameters. Defaults to ``self.params``.
790
+
791
+ Returns
792
+ -------
793
+ grob or gtable
794
+ The rendered guide.
795
+ """
796
+ if params is None:
797
+ params = dict(self.params)
798
+
799
+ # Update position/direction if provided
800
+ if position is not None:
801
+ params["position"] = position
802
+ if direction is not None:
803
+ params["direction"] = direction
804
+
805
+ params = self.setup_params(params)
806
+ elems = self.setup_elements(params, dict(self.elements), theme)
807
+
808
+ # Build components
809
+ key = params.get("key")
810
+ if key is None:
811
+ return None
812
+
813
+ grobs: Dict[str, Any] = {}
814
+ title = params.get("title")
815
+ if not is_waiver(title) and title is not None:
816
+ grobs["title"] = self.build_title(title, elems, params)
817
+
818
+ grobs["labels"] = self.build_labels(key, elems, params)
819
+ grobs["ticks"] = self.build_ticks(key, elems, params)
820
+
821
+ decor = params.get("decor")
822
+ grobs["decor"] = self.build_decor(decor, grobs, elems, params)
823
+
824
+ sizes = self.measure_grobs(grobs, params, elems)
825
+ layout = self.arrange_layout(key, sizes, params, elems)
826
+
827
+ return self.assemble_drawing(grobs, layout, sizes, params, elems)
828
+
829
+
830
+ # ============================================================================
831
+ # GuideNone -- suppresses the guide
832
+ # ============================================================================
833
+
834
+ class GuideNone(Guide):
835
+ """A guide that draws nothing.
836
+
837
+ Attributes
838
+ ----------
839
+ _class_name : str
840
+ ``"GuideNone"``.
841
+ """
842
+
843
+ _class_name: str = "GuideNone"
844
+
845
+ params: Dict[str, Any] = {
846
+ "title": waiver(),
847
+ "theme": None,
848
+ "name": "none",
849
+ "position": waiver(),
850
+ "direction": None,
851
+ "order": 0,
852
+ "hash": "",
853
+ }
854
+
855
+ available_aes: List[str] = ["any"]
856
+
857
+ def train(
858
+ self,
859
+ params: Optional[Dict[str, Any]] = None,
860
+ scale: Any = None,
861
+ aesthetic: Optional[str] = None,
862
+ **kwargs: Any,
863
+ ) -> Dict[str, Any]:
864
+ """Perform no training.
865
+
866
+ Returns
867
+ -------
868
+ dict
869
+ The unmodified parameters.
870
+ """
871
+ return params if params is not None else dict(self.params)
872
+
873
+ @staticmethod
874
+ def transform(params: Dict[str, Any], coord: Any = None, **kwargs: Any) -> Dict[str, Any]:
875
+ """Pass through without transformation.
876
+
877
+ Returns
878
+ -------
879
+ dict
880
+ Unmodified parameters.
881
+ """
882
+ return params
883
+
884
+ def draw(self, **kwargs: Any) -> None:
885
+ """Draw nothing.
886
+
887
+ Returns
888
+ -------
889
+ None
890
+ """
891
+ return None
892
+
893
+
894
+ # ============================================================================
895
+ # GuideAxis -- position axis guide
896
+ # ============================================================================
897
+
898
+ class GuideAxis(Guide):
899
+ """Guide for position axes (x / y).
900
+
901
+ Renders tick marks, labels, and axis lines for position scales.
902
+
903
+ Attributes
904
+ ----------
905
+ _class_name : str
906
+ ``"GuideAxis"``.
907
+ """
908
+
909
+ _class_name: str = "GuideAxis"
910
+
911
+ params: Dict[str, Any] = {
912
+ "title": waiver(),
913
+ "theme": None,
914
+ "name": "axis",
915
+ "hash": "",
916
+ "position": waiver(),
917
+ "direction": None,
918
+ "angle": None,
919
+ "n.dodge": 1,
920
+ "minor.ticks": False,
921
+ "cap": "none",
922
+ "order": 0,
923
+ "check.overlap": False,
924
+ }
925
+
926
+ available_aes: List[str] = ["x", "y"]
927
+
928
+ hashables: List[str] = ["title", "name"]
929
+
930
+ elements: Dict[str, str] = {
931
+ "line": "axis.line",
932
+ "text": "axis.text",
933
+ "ticks": "axis.ticks",
934
+ "minor": "axis.minor.ticks",
935
+ "major_length": "axis.ticks.length",
936
+ "minor_length": "axis.minor.ticks.length",
937
+ }
938
+
939
+ @staticmethod
940
+ def extract_key(
941
+ scale: Any,
942
+ aesthetic: str,
943
+ minor_ticks: bool = False,
944
+ **kwargs: Any,
945
+ ) -> Optional[pd.DataFrame]:
946
+ """Extract break positions for axis guide.
947
+
948
+ Parameters
949
+ ----------
950
+ scale : Scale
951
+ Position scale.
952
+ aesthetic : str
953
+ ``"x"`` or ``"y"``.
954
+ minor_ticks : bool
955
+ Whether to include minor tick positions.
956
+ **kwargs : Any
957
+ Extra arguments.
958
+
959
+ Returns
960
+ -------
961
+ pd.DataFrame or None
962
+ Key with break positions.
963
+ """
964
+ major = Guide.extract_key(scale, aesthetic)
965
+ if major is None:
966
+ major = pd.DataFrame()
967
+ if not minor_ticks:
968
+ return major
969
+
970
+ minor_breaks = getattr(scale, "get_breaks_minor", lambda: [])()
971
+ if minor_breaks is None:
972
+ minor_breaks = []
973
+ if major is not None and not major.empty:
974
+ major_vals = set(major[".value"].tolist())
975
+ minor_breaks = [b for b in minor_breaks
976
+ if b not in major_vals and np.isfinite(b)]
977
+ else:
978
+ minor_breaks = [b for b in minor_breaks if np.isfinite(b)]
979
+
980
+ if not minor_breaks:
981
+ return major
982
+
983
+ mapped = getattr(scale, "map", lambda x: x)(minor_breaks)
984
+ minor = pd.DataFrame({
985
+ aesthetic: mapped,
986
+ ".value": minor_breaks,
987
+ ".type": ["minor"] * len(minor_breaks),
988
+ })
989
+
990
+ if major is not None and not major.empty:
991
+ major = major.copy()
992
+ major[".type"] = "major"
993
+ return pd.concat([major, minor], ignore_index=True)
994
+ return minor
995
+
996
+ @staticmethod
997
+ def extract_params(
998
+ scale: Any,
999
+ params: Dict[str, Any],
1000
+ **kwargs: Any,
1001
+ ) -> Dict[str, Any]:
1002
+ """Append aesthetic name to the guide name.
1003
+
1004
+ Parameters
1005
+ ----------
1006
+ scale : Scale
1007
+ The position scale.
1008
+ params : dict
1009
+ Guide parameters.
1010
+ **kwargs : Any
1011
+ Extra arguments.
1012
+
1013
+ Returns
1014
+ -------
1015
+ dict
1016
+ Updated parameters.
1017
+ """
1018
+ aes = params.get("aesthetic", "")
1019
+ params["name"] = f"{params.get('name', 'axis')}_{aes}"
1020
+ return params
1021
+
1022
+ @staticmethod
1023
+ def extract_decor(
1024
+ scale: Any,
1025
+ aesthetic: str,
1026
+ key: Optional[pd.DataFrame] = None,
1027
+ cap: str = "none",
1028
+ **kwargs: Any,
1029
+ ) -> pd.DataFrame:
1030
+ """Build axis line decoration data.
1031
+
1032
+ Parameters
1033
+ ----------
1034
+ scale : Scale
1035
+ The position scale.
1036
+ aesthetic : str
1037
+ ``"x"`` or ``"y"``.
1038
+ key : pd.DataFrame, optional
1039
+ The guide key.
1040
+ cap : str
1041
+ One of ``"none"``, ``"both"``, ``"upper"``, ``"lower"``.
1042
+ **kwargs : Any
1043
+ Extra arguments.
1044
+
1045
+ Returns
1046
+ -------
1047
+ pd.DataFrame
1048
+ Axis line positions.
1049
+ """
1050
+ value = [-np.inf, np.inf]
1051
+ has_key = key is not None and not key.empty
1052
+ if cap in ("both", "upper") and has_key:
1053
+ value[1] = key[aesthetic].max()
1054
+ if cap in ("both", "lower") and has_key:
1055
+ value[0] = key[aesthetic].min()
1056
+ return pd.DataFrame({aesthetic: value})
1057
+
1058
+ @staticmethod
1059
+ def transform(
1060
+ params: Dict[str, Any],
1061
+ coord: Any,
1062
+ panel_params: Any,
1063
+ ) -> Dict[str, Any]:
1064
+ """Transform axis data through coordinate system.
1065
+
1066
+ Parameters
1067
+ ----------
1068
+ params : dict
1069
+ Guide parameters.
1070
+ coord : Coord
1071
+ Coordinate system.
1072
+ panel_params : object
1073
+ Panel parameters.
1074
+
1075
+ Returns
1076
+ -------
1077
+ dict
1078
+ Transformed parameters.
1079
+ """
1080
+ key = params.get("key")
1081
+ if key is not None and hasattr(coord, "transform") and not key.empty:
1082
+ aesthetic = params.get("aesthetic", "x")
1083
+ ortho = "y" if aesthetic == "x" else "x"
1084
+ position = params.get("position")
1085
+ if position in ("bottom", "left"):
1086
+ override = -np.inf
1087
+ else:
1088
+ override = np.inf
1089
+
1090
+ if not key.empty:
1091
+ if ortho not in key.columns:
1092
+ key = key.copy()
1093
+ key[ortho] = override
1094
+ params["key"] = coord.transform(key, panel_params)
1095
+
1096
+ decor = params.get("decor")
1097
+ if decor is not None and hasattr(coord, "transform"):
1098
+ aesthetic = params.get("aesthetic", "x")
1099
+ ortho = "y" if aesthetic == "x" else "x"
1100
+ if ortho not in decor.columns:
1101
+ decor = decor.copy()
1102
+ position = params.get("position")
1103
+ decor[ortho] = -np.inf if position in ("bottom", "left") else np.inf
1104
+ params["decor"] = coord.transform(decor, panel_params)
1105
+
1106
+ return params
1107
+
1108
+
1109
+ # ============================================================================
1110
+ # GuideLegend -- legend for non-position aesthetics
1111
+ # ============================================================================
1112
+
1113
+ class GuideLegend(Guide):
1114
+ """Legend guide for non-position aesthetics.
1115
+
1116
+ Shows keys (geoms) mapped onto discrete or discretised values.
1117
+
1118
+ Attributes
1119
+ ----------
1120
+ _class_name : str
1121
+ ``"GuideLegend"``.
1122
+ """
1123
+
1124
+ _class_name: str = "GuideLegend"
1125
+
1126
+ params: Dict[str, Any] = {
1127
+ "title": waiver(),
1128
+ "theme": None,
1129
+ "override.aes": {},
1130
+ "nrow": None,
1131
+ "ncol": None,
1132
+ "reverse": False,
1133
+ "order": 0,
1134
+ "name": "legend",
1135
+ "hash": "",
1136
+ "position": None,
1137
+ "direction": None,
1138
+ }
1139
+
1140
+ available_aes: List[str] = ["any"]
1141
+
1142
+ hashables: List[str] = ["title", "name"]
1143
+
1144
+ elements: Dict[str, str] = {
1145
+ "background": "legend.background",
1146
+ "margin": "legend.margin",
1147
+ "key": "legend.key",
1148
+ "key_height": "legend.key.height",
1149
+ "key_width": "legend.key.width",
1150
+ "key_just": "legend.key.justification",
1151
+ "text": "legend.text",
1152
+ "theme.title": "legend.title",
1153
+ "spacing_x": "legend.key.spacing.x",
1154
+ "spacing_y": "legend.key.spacing.y",
1155
+ "text_position": "legend.text.position",
1156
+ "title_position": "legend.title.position",
1157
+ "byrow": "legend.byrow",
1158
+ }
1159
+
1160
+ @staticmethod
1161
+ def extract_params(
1162
+ scale: Any,
1163
+ params: Dict[str, Any],
1164
+ title: Any = None,
1165
+ **kwargs: Any,
1166
+ ) -> Dict[str, Any]:
1167
+ """Extract and validate legend parameters.
1168
+
1169
+ Parameters
1170
+ ----------
1171
+ scale : Scale
1172
+ The mapped scale.
1173
+ params : dict
1174
+ Guide parameters.
1175
+ title : str or Waiver, optional
1176
+ Title override.
1177
+ **kwargs : Any
1178
+ Extra arguments.
1179
+
1180
+ Returns
1181
+ -------
1182
+ dict
1183
+ Updated parameters.
1184
+ """
1185
+ if title is None:
1186
+ title = waiver()
1187
+ # Resolve title
1188
+ scale_name = getattr(scale, "name", None)
1189
+ if is_waiver(params.get("title")):
1190
+ if not is_waiver(title):
1191
+ params["title"] = title
1192
+ elif scale_name is not None:
1193
+ params["title"] = scale_name
1194
+
1195
+ # Reverse key order if requested
1196
+ if params.get("reverse", False):
1197
+ key = params.get("key")
1198
+ if key is not None and isinstance(key, pd.DataFrame) and not key.empty:
1199
+ params["key"] = key.iloc[::-1].reset_index(drop=True)
1200
+ return params
1201
+
1202
+
1203
+ # ============================================================================
1204
+ # GuideColourbar -- continuous colour bar guide
1205
+ # ============================================================================
1206
+
1207
+ class GuideColourbar(GuideLegend):
1208
+ """Continuous colour bar guide.
1209
+
1210
+ Shows a smooth colour gradient representing continuous colour/fill
1211
+ scales.
1212
+
1213
+ Attributes
1214
+ ----------
1215
+ _class_name : str
1216
+ ``"GuideColourbar"``.
1217
+ """
1218
+
1219
+ _class_name: str = "GuideColourbar"
1220
+
1221
+ params: Dict[str, Any] = {
1222
+ "title": waiver(),
1223
+ "theme": None,
1224
+ "nbin": 300,
1225
+ "display": "raster",
1226
+ "alpha": float("nan"),
1227
+ "draw_lim": [True, True],
1228
+ "angle": None,
1229
+ "position": None,
1230
+ "direction": None,
1231
+ "reverse": False,
1232
+ "order": 0,
1233
+ "name": "colourbar",
1234
+ "hash": "",
1235
+ }
1236
+
1237
+ available_aes: List[str] = ["colour", "color", "fill"]
1238
+
1239
+ hashables: List[str] = ["title", "name"]
1240
+
1241
+ elements: Dict[str, str] = {
1242
+ "background": "legend.background",
1243
+ "margin": "legend.margin",
1244
+ "key": "legend.key",
1245
+ "key_height": "legend.key.height",
1246
+ "key_width": "legend.key.width",
1247
+ "text": "legend.text",
1248
+ "theme.title": "legend.title",
1249
+ "ticks": "legend.ticks",
1250
+ "ticks_length": "legend.ticks.length",
1251
+ "frame": "legend.frame",
1252
+ "text_position": "legend.text.position",
1253
+ "title_position": "legend.title.position",
1254
+ }
1255
+
1256
+
1257
+ # ============================================================================
1258
+ # GuideColoursteps -- stepped colour bar guide
1259
+ # ============================================================================
1260
+
1261
+ class GuideColoursteps(GuideColourbar):
1262
+ """Discretised (stepped) colour bar guide.
1263
+
1264
+ Displays areas between breaks as single constant colours instead of
1265
+ a smooth gradient.
1266
+
1267
+ Attributes
1268
+ ----------
1269
+ _class_name : str
1270
+ ``"GuideColoursteps"``.
1271
+ """
1272
+
1273
+ _class_name: str = "GuideColoursteps"
1274
+
1275
+ params: Dict[str, Any] = {
1276
+ **GuideColourbar.params,
1277
+ "even.steps": True,
1278
+ "show.limits": None,
1279
+ "name": "coloursteps",
1280
+ }
1281
+
1282
+ available_aes: List[str] = ["colour", "color", "fill"]
1283
+
1284
+
1285
+ # ============================================================================
1286
+ # GuideBins -- binned legend guide
1287
+ # ============================================================================
1288
+
1289
+ class GuideBins(GuideLegend):
1290
+ """Binned legend guide.
1291
+
1292
+ A version of the legend guide for binned scales. Places ticks between
1293
+ keys and optionally shows a small axis.
1294
+
1295
+ Attributes
1296
+ ----------
1297
+ _class_name : str
1298
+ ``"GuideBins"``.
1299
+ """
1300
+
1301
+ _class_name: str = "GuideBins"
1302
+
1303
+ params: Dict[str, Any] = {
1304
+ **GuideLegend.params,
1305
+ "angle": None,
1306
+ "show.limits": None,
1307
+ "name": "bins",
1308
+ }
1309
+
1310
+ available_aes: List[str] = ["any"]
1311
+
1312
+ elements: Dict[str, str] = {
1313
+ **GuideLegend.elements,
1314
+ "axis_line": "legend.axis.line",
1315
+ }
1316
+
1317
+
1318
+ # ============================================================================
1319
+ # GuideCustom -- user-supplied grob guide
1320
+ # ============================================================================
1321
+
1322
+ class GuideCustom(Guide):
1323
+ """Custom guide that displays a user-supplied grob.
1324
+
1325
+ Attributes
1326
+ ----------
1327
+ _class_name : str
1328
+ ``"GuideCustom"``.
1329
+ """
1330
+
1331
+ _class_name: str = "GuideCustom"
1332
+
1333
+ params: Dict[str, Any] = {
1334
+ **Guide.params,
1335
+ "grob": None,
1336
+ "width": None,
1337
+ "height": None,
1338
+ "name": "custom",
1339
+ }
1340
+
1341
+ available_aes: List[str] = ["any"]
1342
+
1343
+ hashables: List[str] = ["title", "grob"]
1344
+
1345
+ elements: Dict[str, str] = {
1346
+ "background": "legend.background",
1347
+ "margin": "legend.margin",
1348
+ "title": "legend.title",
1349
+ "title_position": "legend.title.position",
1350
+ }
1351
+
1352
+ def train(
1353
+ self,
1354
+ params: Optional[Dict[str, Any]] = None,
1355
+ scale: Any = None,
1356
+ aesthetic: Optional[str] = None,
1357
+ **kwargs: Any,
1358
+ ) -> Dict[str, Any]:
1359
+ """Custom guides skip training.
1360
+
1361
+ Returns
1362
+ -------
1363
+ dict
1364
+ Unchanged parameters.
1365
+ """
1366
+ return params if params is not None else dict(self.params)
1367
+
1368
+ @staticmethod
1369
+ def transform(params: Dict[str, Any], coord: Any = None, **kwargs: Any) -> Dict[str, Any]:
1370
+ """Pass through without transformation.
1371
+
1372
+ Returns
1373
+ -------
1374
+ dict
1375
+ Unmodified parameters.
1376
+ """
1377
+ return params
1378
+
1379
+ def draw(
1380
+ self,
1381
+ theme: Any = None,
1382
+ position: Optional[str] = None,
1383
+ direction: Optional[str] = None,
1384
+ params: Optional[Dict[str, Any]] = None,
1385
+ ) -> Any:
1386
+ """Draw the custom grob with optional title.
1387
+
1388
+ Parameters
1389
+ ----------
1390
+ theme : Theme, optional
1391
+ Plot theme.
1392
+ position : str, optional
1393
+ Legend position.
1394
+ direction : str, optional
1395
+ Legend direction.
1396
+ params : dict, optional
1397
+ Guide parameters.
1398
+
1399
+ Returns
1400
+ -------
1401
+ grob
1402
+ The custom grob.
1403
+ """
1404
+ if params is None:
1405
+ params = dict(self.params)
1406
+ return params.get("grob")
1407
+
1408
+
1409
+ # ============================================================================
1410
+ # GuideAxisLogticks -- log-scale tick marks
1411
+ # ============================================================================
1412
+
1413
+ class GuideAxisLogticks(GuideAxis):
1414
+ """Axis guide with logarithmic tick marks.
1415
+
1416
+ Replaces standard tick placement with ticks at log10-spaced intervals.
1417
+
1418
+ Attributes
1419
+ ----------
1420
+ _class_name : str
1421
+ ``"GuideAxisLogticks"``.
1422
+ """
1423
+
1424
+ _class_name: str = "GuideAxisLogticks"
1425
+
1426
+ params: Dict[str, Any] = {
1427
+ **GuideAxis.params,
1428
+ "long": 2.25,
1429
+ "mid": 1.5,
1430
+ "short": 0.75,
1431
+ "prescale.base": None,
1432
+ "negative.small": None,
1433
+ "short.theme": None,
1434
+ "expanded": True,
1435
+ "name": "axis_logticks",
1436
+ }
1437
+
1438
+ available_aes: List[str] = ["x", "y"]
1439
+
1440
+
1441
+ # ============================================================================
1442
+ # GuideAxisStack -- stacked axis guides
1443
+ # ============================================================================
1444
+
1445
+ class GuideAxisStack(GuideAxis):
1446
+ """Stacked axis guide combining multiple axis guides.
1447
+
1448
+ Attributes
1449
+ ----------
1450
+ _class_name : str
1451
+ ``"GuideAxisStack"``.
1452
+ """
1453
+
1454
+ _class_name: str = "GuideAxisStack"
1455
+
1456
+ params: Dict[str, Any] = {
1457
+ "guides": [],
1458
+ "guide_params": [],
1459
+ "spacing": None,
1460
+ "name": "stacked_axis",
1461
+ "title": waiver(),
1462
+ "theme": None,
1463
+ "angle": waiver(),
1464
+ "hash": "",
1465
+ "position": waiver(),
1466
+ "direction": None,
1467
+ "order": 0,
1468
+ }
1469
+
1470
+ available_aes: List[str] = ["x", "y", "theta", "r"]
1471
+
1472
+
1473
+ # ============================================================================
1474
+ # GuideAxisTheta -- angle axis for radial coordinates
1475
+ # ============================================================================
1476
+
1477
+ class GuideAxisTheta(GuideAxis):
1478
+ """Angle axis guide for polar / radial coordinates.
1479
+
1480
+ Attributes
1481
+ ----------
1482
+ _class_name : str
1483
+ ``"GuideAxisTheta"``.
1484
+ """
1485
+
1486
+ _class_name: str = "GuideAxisTheta"
1487
+
1488
+ params: Dict[str, Any] = {
1489
+ **GuideAxis.params,
1490
+ "name": "axis_theta",
1491
+ }
1492
+
1493
+ available_aes: List[str] = ["x", "y", "theta"]
1494
+
1495
+ @staticmethod
1496
+ def transform(
1497
+ params: Dict[str, Any],
1498
+ coord: Any,
1499
+ panel_params: Any,
1500
+ ) -> Dict[str, Any]:
1501
+ """Transform data for theta axis.
1502
+
1503
+ Delegates to :meth:`GuideAxis.transform` and then adds
1504
+ ``theta`` column for label angle computation.
1505
+
1506
+ Parameters
1507
+ ----------
1508
+ params : dict
1509
+ Guide parameters.
1510
+ coord : Coord
1511
+ Coordinate system.
1512
+ panel_params : object
1513
+ Panel parameters.
1514
+
1515
+ Returns
1516
+ -------
1517
+ dict
1518
+ Transformed parameters.
1519
+ """
1520
+ params = GuideAxis.transform(params, coord, panel_params)
1521
+ key = params.get("key")
1522
+ if key is not None and not key.empty and "theta" not in key.columns:
1523
+ position = params.get("position", "bottom")
1524
+ theta_map = {
1525
+ "top": 0.0,
1526
+ "bottom": np.pi,
1527
+ "left": 1.5 * np.pi,
1528
+ "right": 0.5 * np.pi,
1529
+ }
1530
+ key = key.copy()
1531
+ key["theta"] = theta_map.get(position, 0.0)
1532
+ params["key"] = key
1533
+ return params
1534
+
1535
+
1536
+ # ============================================================================
1537
+ # GuideOld -- legacy S3 compatibility wrapper
1538
+ # ============================================================================
1539
+
1540
+ class GuideOld(Guide):
1541
+ """Compatibility wrapper for the previous S3-based guide system.
1542
+
1543
+ The old S3 methods (``guide_train``, ``guide_merge``, etc.) are
1544
+ dispatched through this class as a fallback.
1545
+
1546
+ Attributes
1547
+ ----------
1548
+ _class_name : str
1549
+ ``"GuideOld"``.
1550
+ """
1551
+
1552
+ _class_name: str = "GuideOld"
1553
+
1554
+
1555
+ # ============================================================================
1556
+ # new_guide() -- Guide constructor factory
1557
+ # ============================================================================
1558
+
1559
+ def new_guide(
1560
+ *,
1561
+ available_aes: Union[str, List[str]] = "any",
1562
+ super: type = Guide, # noqa: A002 (shadows builtin intentionally)
1563
+ **kwargs: Any,
1564
+ ) -> Guide:
1565
+ """Construct a Guide instance with validated parameters.
1566
+
1567
+ Parameters
1568
+ ----------
1569
+ available_aes : str or list of str
1570
+ Aesthetics supported by this guide. ``"any"`` matches all
1571
+ non-position aesthetics.
1572
+ super : type
1573
+ The Guide (sub)class to instantiate.
1574
+ **kwargs : Any
1575
+ Parameter overrides. Must be a subset of ``super.params`` keys.
1576
+
1577
+ Returns
1578
+ -------
1579
+ Guide
1580
+ A new guide instance.
1581
+
1582
+ Raises
1583
+ ------
1584
+ ValueError
1585
+ If required parameters are missing.
1586
+ """
1587
+ if isinstance(available_aes, str):
1588
+ available_aes = [available_aes]
1589
+
1590
+ # Determine valid parameter names
1591
+ param_names = set(super.params.keys()) if hasattr(super, "params") else set()
1592
+
1593
+ # Split into params vs extra
1594
+ params: Dict[str, Any] = {}
1595
+ extra_args: List[str] = []
1596
+ for k, v in kwargs.items():
1597
+ if k in param_names:
1598
+ params[k] = v
1599
+ else:
1600
+ extra_args.append(k)
1601
+
1602
+ if extra_args:
1603
+ cls_name = snake_class(super) if hasattr(super, "_class_name") else str(super)
1604
+ cli_warn(
1605
+ f"Ignoring unknown argument(s) to {cls_name}: "
1606
+ f"{', '.join(extra_args)}."
1607
+ )
1608
+
1609
+ # Fill defaults
1610
+ if hasattr(super, "params"):
1611
+ merged = dict(super.params)
1612
+ merged.update(params)
1613
+ params = merged
1614
+
1615
+ # Validate required base Guide params
1616
+ required = set(Guide.params.keys())
1617
+ missing = required - set(params.keys())
1618
+ if missing:
1619
+ cli_abort(
1620
+ f"The following parameters are required for setting up a guide "
1621
+ f"but are missing: {', '.join(sorted(missing))}"
1622
+ )
1623
+
1624
+ # Validate theme
1625
+ theme = params.get("theme")
1626
+ if theme is not None:
1627
+ direction = params.get("direction")
1628
+ if direction is None and hasattr(theme, "get"):
1629
+ params["direction"] = theme.get("legend.direction")
1630
+
1631
+ # Ensure order is an integer
1632
+ params["order"] = int(params.get("order", 0))
1633
+
1634
+ # Create instance
1635
+ instance = super()
1636
+ instance.params = params
1637
+ instance.available_aes = list(available_aes)
1638
+ return instance
1639
+
1640
+
1641
+ # ============================================================================
1642
+ # Constructor functions
1643
+ # ============================================================================
1644
+
1645
+ def guide_none(
1646
+ title: Any = waiver(),
1647
+ position: Any = waiver(),
1648
+ ) -> GuideNone:
1649
+ """Create a guide that draws nothing.
1650
+
1651
+ Parameters
1652
+ ----------
1653
+ title : str or Waiver
1654
+ Guide title (unused but kept for interface consistency).
1655
+ position : str or Waiver
1656
+ Position hint.
1657
+
1658
+ Returns
1659
+ -------
1660
+ GuideNone
1661
+ An empty guide.
1662
+ """
1663
+ return new_guide(
1664
+ title=title,
1665
+ position=position,
1666
+ available_aes="any",
1667
+ super=GuideNone,
1668
+ )
1669
+
1670
+
1671
+ def guide_axis(
1672
+ title: Any = waiver(),
1673
+ theme: Any = None,
1674
+ check_overlap: bool = False,
1675
+ angle: Any = waiver(),
1676
+ n_dodge: int = 1,
1677
+ minor_ticks: bool = False,
1678
+ cap: Union[str, bool] = "none",
1679
+ order: int = 0,
1680
+ position: Any = waiver(),
1681
+ ) -> GuideAxis:
1682
+ """Create an axis guide.
1683
+
1684
+ Parameters
1685
+ ----------
1686
+ title : str or Waiver
1687
+ Axis title.
1688
+ theme : Theme, optional
1689
+ Theme overrides.
1690
+ check_overlap : bool
1691
+ Silently remove overlapping labels.
1692
+ angle : float or Waiver
1693
+ Text angle in degrees.
1694
+ n_dodge : int
1695
+ Number of rows/columns for dodging labels.
1696
+ minor_ticks : bool
1697
+ Whether to draw minor ticks.
1698
+ cap : str or bool
1699
+ Axis line capping: ``"none"``, ``"both"``, ``"upper"``,
1700
+ ``"lower"``, ``True`` (="both"), or ``False`` (="none").
1701
+ order : int
1702
+ Guide ordering priority.
1703
+ position : str or Waiver
1704
+ Where the axis is drawn.
1705
+
1706
+ Returns
1707
+ -------
1708
+ GuideAxis
1709
+ An axis guide instance.
1710
+ """
1711
+ if isinstance(cap, bool):
1712
+ cap = "both" if cap else "none"
1713
+ if cap not in ("none", "both", "upper", "lower"):
1714
+ cli_abort(f"`cap` must be one of 'none', 'both', 'upper', 'lower', got {cap!r}")
1715
+
1716
+ return new_guide(
1717
+ title=title,
1718
+ theme=theme,
1719
+ **{
1720
+ "check.overlap": check_overlap,
1721
+ },
1722
+ angle=angle,
1723
+ **{
1724
+ "n.dodge": n_dodge,
1725
+ "minor.ticks": minor_ticks,
1726
+ },
1727
+ cap=cap,
1728
+ order=order,
1729
+ position=position,
1730
+ available_aes=["x", "y", "r"],
1731
+ name="axis",
1732
+ super=GuideAxis,
1733
+ )
1734
+
1735
+
1736
+ def guide_legend(
1737
+ title: Any = waiver(),
1738
+ theme: Any = None,
1739
+ position: Optional[str] = None,
1740
+ direction: Optional[str] = None,
1741
+ override_aes: Optional[Dict[str, Any]] = None,
1742
+ nrow: Optional[int] = None,
1743
+ ncol: Optional[int] = None,
1744
+ reverse: bool = False,
1745
+ order: int = 0,
1746
+ **kwargs: Any,
1747
+ ) -> GuideLegend:
1748
+ """Create a legend guide.
1749
+
1750
+ Parameters
1751
+ ----------
1752
+ title : str or Waiver
1753
+ Legend title.
1754
+ theme : Theme, optional
1755
+ Theme overrides.
1756
+ position : str, optional
1757
+ One of ``"top"``, ``"right"``, ``"bottom"``, ``"left"``, or
1758
+ ``"inside"``.
1759
+ direction : str, optional
1760
+ ``"horizontal"`` or ``"vertical"``.
1761
+ override_aes : dict, optional
1762
+ Aesthetic parameters to override in the legend keys.
1763
+ nrow : int, optional
1764
+ Number of rows.
1765
+ ncol : int, optional
1766
+ Number of columns.
1767
+ reverse : bool
1768
+ Reverse the order of keys.
1769
+ order : int
1770
+ Guide ordering priority.
1771
+ **kwargs : Any
1772
+ Ignored (for compatibility).
1773
+
1774
+ Returns
1775
+ -------
1776
+ GuideLegend
1777
+ A legend guide.
1778
+ """
1779
+ if position is not None and position not in _TRBL + ["inside"]:
1780
+ cli_abort(
1781
+ f"`position` must be one of {_TRBL + ['inside']!r}, got {position!r}"
1782
+ )
1783
+ if override_aes is None:
1784
+ override_aes = {}
1785
+
1786
+ return new_guide(
1787
+ title=title,
1788
+ theme=theme,
1789
+ direction=direction,
1790
+ **{"override.aes": override_aes},
1791
+ nrow=nrow,
1792
+ ncol=ncol,
1793
+ reverse=reverse,
1794
+ order=order,
1795
+ position=position,
1796
+ available_aes="any",
1797
+ name="legend",
1798
+ super=GuideLegend,
1799
+ )
1800
+
1801
+
1802
+ def guide_colourbar(
1803
+ title: Any = waiver(),
1804
+ theme: Any = None,
1805
+ nbin: Optional[int] = None,
1806
+ display: str = "raster",
1807
+ alpha: float = float("nan"),
1808
+ draw_ulim: bool = True,
1809
+ draw_llim: bool = True,
1810
+ angle: Optional[float] = None,
1811
+ position: Optional[str] = None,
1812
+ direction: Optional[str] = None,
1813
+ reverse: bool = False,
1814
+ order: int = 0,
1815
+ available_aes: Optional[List[str]] = None,
1816
+ **kwargs: Any,
1817
+ ) -> GuideColourbar:
1818
+ """Create a continuous colour bar guide.
1819
+
1820
+ Parameters
1821
+ ----------
1822
+ title : str or Waiver
1823
+ Guide title.
1824
+ theme : Theme, optional
1825
+ Theme overrides.
1826
+ nbin : int, optional
1827
+ Number of bins. Defaults to 300 for raster/rectangles, 15 for
1828
+ gradient.
1829
+ display : str
1830
+ ``"raster"``, ``"rectangles"``, or ``"gradient"``.
1831
+ alpha : float
1832
+ Colour transparency (0--1). ``NaN`` preserves encoded alpha.
1833
+ draw_ulim : bool
1834
+ Draw upper limit tick.
1835
+ draw_llim : bool
1836
+ Draw lower limit tick.
1837
+ angle : float, optional
1838
+ Label angle.
1839
+ position : str, optional
1840
+ Legend position.
1841
+ direction : str, optional
1842
+ ``"horizontal"`` or ``"vertical"``.
1843
+ reverse : bool
1844
+ Reverse colour bar direction.
1845
+ order : int
1846
+ Guide ordering priority.
1847
+ available_aes : list of str, optional
1848
+ Supported aesthetics. Defaults to colour/color/fill.
1849
+ **kwargs : Any
1850
+ Ignored.
1851
+
1852
+ Returns
1853
+ -------
1854
+ GuideColourbar
1855
+ A colour bar guide.
1856
+ """
1857
+ if display not in ("raster", "rectangles", "gradient"):
1858
+ cli_abort(f"`display` must be 'raster', 'rectangles', or 'gradient', got {display!r}")
1859
+ if nbin is None:
1860
+ nbin = 15 if display == "gradient" else 300
1861
+
1862
+ if position is not None and position not in _TRBL + ["inside"]:
1863
+ cli_abort(f"`position` must be one of {_TRBL + ['inside']!r}, got {position!r}")
1864
+
1865
+ if available_aes is None:
1866
+ available_aes = ["colour", "color", "fill"]
1867
+
1868
+ return new_guide(
1869
+ title=title,
1870
+ theme=theme,
1871
+ nbin=nbin,
1872
+ display=display,
1873
+ alpha=alpha,
1874
+ angle=angle,
1875
+ draw_lim=[bool(draw_llim), bool(draw_ulim)],
1876
+ position=position,
1877
+ direction=direction,
1878
+ reverse=reverse,
1879
+ order=order,
1880
+ available_aes=available_aes,
1881
+ name="colourbar",
1882
+ super=GuideColourbar,
1883
+ )
1884
+
1885
+
1886
+ # Alias
1887
+ guide_colorbar = guide_colourbar
1888
+ """Alias for :func:`guide_colourbar`."""
1889
+
1890
+
1891
+ def guide_coloursteps(
1892
+ title: Any = waiver(),
1893
+ theme: Any = None,
1894
+ alpha: float = float("nan"),
1895
+ angle: Optional[float] = None,
1896
+ even_steps: bool = True,
1897
+ show_limits: Optional[bool] = None,
1898
+ direction: Optional[str] = None,
1899
+ position: Optional[str] = None,
1900
+ reverse: bool = False,
1901
+ order: int = 0,
1902
+ available_aes: Optional[List[str]] = None,
1903
+ **kwargs: Any,
1904
+ ) -> GuideColoursteps:
1905
+ """Create a stepped colour bar guide.
1906
+
1907
+ Parameters
1908
+ ----------
1909
+ title : str or Waiver
1910
+ Guide title.
1911
+ theme : Theme, optional
1912
+ Theme overrides.
1913
+ alpha : float
1914
+ Colour transparency.
1915
+ angle : float, optional
1916
+ Label angle.
1917
+ even_steps : bool
1918
+ Make all bins the same rendered size.
1919
+ show_limits : bool, optional
1920
+ Show scale limits.
1921
+ direction : str, optional
1922
+ ``"horizontal"`` or ``"vertical"``.
1923
+ position : str, optional
1924
+ Legend position.
1925
+ reverse : bool
1926
+ Reverse colour bar.
1927
+ order : int
1928
+ Guide ordering priority.
1929
+ available_aes : list of str, optional
1930
+ Supported aesthetics.
1931
+ **kwargs : Any
1932
+ Ignored.
1933
+
1934
+ Returns
1935
+ -------
1936
+ GuideColoursteps
1937
+ A stepped colour bar guide.
1938
+ """
1939
+ if available_aes is None:
1940
+ available_aes = ["colour", "color", "fill"]
1941
+
1942
+ return new_guide(
1943
+ title=title,
1944
+ theme=theme,
1945
+ alpha=alpha,
1946
+ angle=angle,
1947
+ **{
1948
+ "even.steps": even_steps,
1949
+ "show.limits": show_limits,
1950
+ },
1951
+ position=position,
1952
+ direction=direction,
1953
+ reverse=reverse,
1954
+ order=order,
1955
+ available_aes=available_aes,
1956
+ super=GuideColoursteps,
1957
+ )
1958
+
1959
+
1960
+ # Alias
1961
+ guide_colorsteps = guide_coloursteps
1962
+ """Alias for :func:`guide_coloursteps`."""
1963
+
1964
+
1965
+ def guide_bins(
1966
+ title: Any = waiver(),
1967
+ theme: Any = None,
1968
+ angle: Optional[float] = None,
1969
+ position: Optional[str] = None,
1970
+ direction: Optional[str] = None,
1971
+ override_aes: Optional[Dict[str, Any]] = None,
1972
+ reverse: bool = False,
1973
+ order: int = 0,
1974
+ show_limits: Optional[bool] = None,
1975
+ **kwargs: Any,
1976
+ ) -> GuideBins:
1977
+ """Create a binned legend guide.
1978
+
1979
+ Parameters
1980
+ ----------
1981
+ title : str or Waiver
1982
+ Guide title.
1983
+ theme : Theme, optional
1984
+ Theme overrides.
1985
+ angle : float, optional
1986
+ Label angle.
1987
+ position : str, optional
1988
+ Legend position.
1989
+ direction : str, optional
1990
+ ``"horizontal"`` or ``"vertical"``.
1991
+ override_aes : dict, optional
1992
+ Aesthetic overrides for keys.
1993
+ reverse : bool
1994
+ Reverse key order.
1995
+ order : int
1996
+ Guide ordering priority.
1997
+ show_limits : bool, optional
1998
+ Show scale limits.
1999
+ **kwargs : Any
2000
+ Ignored.
2001
+
2002
+ Returns
2003
+ -------
2004
+ GuideBins
2005
+ A binned legend guide.
2006
+ """
2007
+ if position is not None and position not in _TRBL + ["inside"]:
2008
+ cli_abort(f"`position` must be one of {_TRBL + ['inside']!r}, got {position!r}")
2009
+ if override_aes is None:
2010
+ override_aes = {}
2011
+
2012
+ return new_guide(
2013
+ title=title,
2014
+ theme=theme,
2015
+ angle=angle,
2016
+ position=position,
2017
+ direction=direction,
2018
+ **{
2019
+ "override.aes": override_aes,
2020
+ "show.limits": show_limits,
2021
+ },
2022
+ reverse=reverse,
2023
+ order=order,
2024
+ available_aes="any",
2025
+ name="bins",
2026
+ super=GuideBins,
2027
+ )
2028
+
2029
+
2030
+ def guide_custom(
2031
+ grob: Any,
2032
+ width: Any = None,
2033
+ height: Any = None,
2034
+ title: Optional[str] = None,
2035
+ theme: Any = None,
2036
+ position: Optional[str] = None,
2037
+ order: int = 0,
2038
+ ) -> GuideCustom:
2039
+ """Create a custom guide displaying a user-supplied grob.
2040
+
2041
+ Parameters
2042
+ ----------
2043
+ grob : grob
2044
+ The graphical object to display.
2045
+ width : unit, optional
2046
+ Allocated width.
2047
+ height : unit, optional
2048
+ Allocated height.
2049
+ title : str, optional
2050
+ Guide title. ``None`` means no title.
2051
+ theme : Theme, optional
2052
+ Theme overrides.
2053
+ position : str, optional
2054
+ Legend position.
2055
+ order : int
2056
+ Guide ordering priority.
2057
+
2058
+ Returns
2059
+ -------
2060
+ GuideCustom
2061
+ A custom guide.
2062
+ """
2063
+ return new_guide(
2064
+ grob=grob,
2065
+ width=width,
2066
+ height=height,
2067
+ title=title,
2068
+ theme=theme,
2069
+ hash=_hash_object([title, grob]),
2070
+ position=position,
2071
+ order=order,
2072
+ available_aes="any",
2073
+ super=GuideCustom,
2074
+ )
2075
+
2076
+
2077
+ def guide_axis_logticks(
2078
+ long: float = 2.25,
2079
+ mid: float = 1.5,
2080
+ short: float = 0.75,
2081
+ prescale_base: Optional[float] = None,
2082
+ negative_small: Optional[float] = None,
2083
+ short_theme: Any = None,
2084
+ expanded: bool = True,
2085
+ cap: Union[str, bool] = "none",
2086
+ theme: Any = None,
2087
+ title: Any = waiver(),
2088
+ order: int = 0,
2089
+ position: Any = waiver(),
2090
+ **kwargs: Any,
2091
+ ) -> GuideAxisLogticks:
2092
+ """Create an axis guide with log-spaced tick marks.
2093
+
2094
+ Parameters
2095
+ ----------
2096
+ long : float
2097
+ Relative length of long (decade) ticks.
2098
+ mid : float
2099
+ Relative length of mid ticks.
2100
+ short : float
2101
+ Relative length of short ticks.
2102
+ prescale_base : float, optional
2103
+ Log base for pre-transformed data.
2104
+ negative_small : float, optional
2105
+ Smallest absolute value ticked when 0 is included.
2106
+ short_theme : element, optional
2107
+ Theme element for shortest ticks.
2108
+ expanded : bool
2109
+ Cover expanded range.
2110
+ cap : str or bool
2111
+ Axis line capping.
2112
+ theme : Theme, optional
2113
+ Theme overrides.
2114
+ title : str or Waiver
2115
+ Axis title.
2116
+ order : int
2117
+ Guide ordering priority.
2118
+ position : str or Waiver
2119
+ Axis position.
2120
+ **kwargs : Any
2121
+ Forwarded to :func:`guide_axis`.
2122
+
2123
+ Returns
2124
+ -------
2125
+ GuideAxisLogticks
2126
+ A log-tick axis guide.
2127
+ """
2128
+ if isinstance(cap, bool):
2129
+ cap = "both" if cap else "none"
2130
+
2131
+ return new_guide(
2132
+ title=title,
2133
+ theme=theme,
2134
+ long=long,
2135
+ mid=mid,
2136
+ short=short,
2137
+ **{
2138
+ "prescale.base": prescale_base,
2139
+ "negative.small": negative_small,
2140
+ "short.theme": short_theme,
2141
+ },
2142
+ expanded=expanded,
2143
+ cap=cap,
2144
+ order=order,
2145
+ position=position,
2146
+ available_aes=["x", "y"],
2147
+ name="axis_logticks",
2148
+ super=GuideAxisLogticks,
2149
+ )
2150
+
2151
+
2152
+ def guide_axis_stack(
2153
+ first: Any = "axis",
2154
+ *args: Any,
2155
+ title: Any = waiver(),
2156
+ theme: Any = None,
2157
+ spacing: Any = None,
2158
+ order: int = 0,
2159
+ position: Any = waiver(),
2160
+ ) -> GuideAxisStack:
2161
+ """Create a stacked axis guide.
2162
+
2163
+ Parameters
2164
+ ----------
2165
+ first : str or Guide
2166
+ The innermost axis guide.
2167
+ *args : str or Guide
2168
+ Additional axis guides to stack.
2169
+ title : str or Waiver
2170
+ Axis title.
2171
+ theme : Theme, optional
2172
+ Theme overrides.
2173
+ spacing : unit, optional
2174
+ Space between stacked guides.
2175
+ order : int
2176
+ Guide ordering priority.
2177
+ position : str or Waiver
2178
+ Axis position.
2179
+
2180
+ Returns
2181
+ -------
2182
+ GuideAxisStack
2183
+ A stacked axis guide.
2184
+ """
2185
+ axes = [_validate_guide(first)] + [_validate_guide(a) for a in args]
2186
+ guide_params = [dict(getattr(a, "params", {})) for a in axes]
2187
+
2188
+ return new_guide(
2189
+ title=title,
2190
+ theme=theme,
2191
+ guides=axes,
2192
+ guide_params=guide_params,
2193
+ spacing=spacing,
2194
+ available_aes=["x", "y", "theta", "r"],
2195
+ order=order,
2196
+ position=position,
2197
+ name="stacked_axis",
2198
+ super=GuideAxisStack,
2199
+ )
2200
+
2201
+
2202
+ def guide_axis_theta(
2203
+ title: Any = waiver(),
2204
+ theme: Any = None,
2205
+ angle: Any = waiver(),
2206
+ minor_ticks: bool = False,
2207
+ cap: Union[str, bool] = "none",
2208
+ order: int = 0,
2209
+ position: Any = waiver(),
2210
+ ) -> GuideAxisTheta:
2211
+ """Create an angle axis guide for radial coordinates.
2212
+
2213
+ Parameters
2214
+ ----------
2215
+ title : str or Waiver
2216
+ Axis title.
2217
+ theme : Theme, optional
2218
+ Theme overrides.
2219
+ angle : float or Waiver
2220
+ Text angle.
2221
+ minor_ticks : bool
2222
+ Draw minor ticks.
2223
+ cap : str or bool
2224
+ Axis line capping.
2225
+ order : int
2226
+ Guide ordering priority.
2227
+ position : str or Waiver
2228
+ Axis position.
2229
+
2230
+ Returns
2231
+ -------
2232
+ GuideAxisTheta
2233
+ A theta axis guide.
2234
+ """
2235
+ if isinstance(cap, bool):
2236
+ cap = "both" if cap else "none"
2237
+
2238
+ return new_guide(
2239
+ title=title,
2240
+ theme=theme,
2241
+ angle=angle,
2242
+ cap=cap,
2243
+ **{"minor.ticks": minor_ticks},
2244
+ available_aes=["x", "y", "theta"],
2245
+ order=order,
2246
+ position=position,
2247
+ name="axis_theta",
2248
+ super=GuideAxisTheta,
2249
+ )
2250
+
2251
+
2252
+ # ============================================================================
2253
+ # Legacy S3 compatibility functions
2254
+ # ============================================================================
2255
+
2256
+ def old_guide(guide: Any) -> GuideOld:
2257
+ """Wrap a legacy guide object.
2258
+
2259
+ Parameters
2260
+ ----------
2261
+ guide : object
2262
+ An old-style guide.
2263
+
2264
+ Returns
2265
+ -------
2266
+ GuideOld
2267
+ Wrapped guide.
2268
+ """
2269
+ instance = GuideOld()
2270
+ instance._legacy = guide
2271
+ return instance
2272
+
2273
+
2274
+ def guide_train(guide: Any, scale: Any, aesthetic: Optional[str] = None) -> Any:
2275
+ """Legacy S3-style ``guide_train`` dispatch.
2276
+
2277
+ Parameters
2278
+ ----------
2279
+ guide : Guide
2280
+ The guide to train.
2281
+ scale : Scale
2282
+ Scale to train on.
2283
+ aesthetic : str, optional
2284
+ Aesthetic name.
2285
+
2286
+ Returns
2287
+ -------
2288
+ Any
2289
+ Trained guide parameters.
2290
+ """
2291
+ if hasattr(guide, "train"):
2292
+ return guide.train(params=dict(getattr(guide, "params", {})),
2293
+ scale=scale, aesthetic=aesthetic)
2294
+ cli_abort("Guide classes have been rewritten as GGProto classes. "
2295
+ "The old S3 guide methods have been superseded.")
2296
+
2297
+
2298
+ def guide_merge(guide: Any, new_guide: Any) -> Any:
2299
+ """Legacy S3-style ``guide_merge`` dispatch.
2300
+
2301
+ Parameters
2302
+ ----------
2303
+ guide : Guide
2304
+ Primary guide.
2305
+ new_guide : Guide
2306
+ Guide to merge in.
2307
+
2308
+ Returns
2309
+ -------
2310
+ Any
2311
+ Merged guide.
2312
+ """
2313
+ if hasattr(guide, "merge"):
2314
+ return guide.merge(dict(getattr(guide, "params", {})),
2315
+ new_guide,
2316
+ dict(getattr(new_guide, "params", {})))
2317
+ cli_abort("Guide classes have been rewritten as GGProto classes. "
2318
+ "The old S3 guide methods have been superseded.")
2319
+
2320
+
2321
+ def guide_geom(guide: Any, layers: Any = None, default_mapping: Any = None) -> Any:
2322
+ """Legacy S3-style ``guide_geom`` dispatch.
2323
+
2324
+ Parameters
2325
+ ----------
2326
+ guide : Guide
2327
+ The guide.
2328
+ layers : list, optional
2329
+ Plot layers.
2330
+ default_mapping : Mapping, optional
2331
+ Default aesthetic mapping.
2332
+
2333
+ Returns
2334
+ -------
2335
+ Any
2336
+ Geom info.
2337
+ """
2338
+ if hasattr(guide, "process_layers"):
2339
+ return guide.process_layers(dict(getattr(guide, "params", {})),
2340
+ layers or [])
2341
+ cli_abort("Guide classes have been rewritten as GGProto classes. "
2342
+ "The old S3 guide methods have been superseded.")
2343
+
2344
+
2345
+ def guide_transform(guide: Any, coord: Any, panel_params: Any) -> Any:
2346
+ """Legacy S3-style ``guide_transform`` dispatch.
2347
+
2348
+ Parameters
2349
+ ----------
2350
+ guide : Guide
2351
+ The guide.
2352
+ coord : Coord
2353
+ Coordinate system.
2354
+ panel_params : object
2355
+ Panel parameters.
2356
+
2357
+ Returns
2358
+ -------
2359
+ Any
2360
+ Transformed parameters.
2361
+ """
2362
+ if hasattr(guide, "transform"):
2363
+ return guide.transform(dict(getattr(guide, "params", {})),
2364
+ coord, panel_params)
2365
+ cli_abort("Guide classes have been rewritten as GGProto classes. "
2366
+ "The old S3 guide methods have been superseded.")
2367
+
2368
+
2369
+ def guide_gengrob(guide: Any, theme: Any) -> Any:
2370
+ """Legacy S3-style ``guide_gengrob`` dispatch.
2371
+
2372
+ Parameters
2373
+ ----------
2374
+ guide : Guide
2375
+ The guide.
2376
+ theme : Theme
2377
+ Plot theme.
2378
+
2379
+ Returns
2380
+ -------
2381
+ Any
2382
+ Generated grob.
2383
+ """
2384
+ if hasattr(guide, "draw"):
2385
+ return guide.draw(theme=theme)
2386
+ cli_abort("Guide classes have been rewritten as GGProto classes. "
2387
+ "The old S3 guide methods have been superseded.")
2388
+
2389
+
2390
+ # ============================================================================
2391
+ # Type-checking helpers
2392
+ # ============================================================================
2393
+
2394
+ def is_guide(x: Any) -> bool:
2395
+ """Test whether *x* is a Guide.
2396
+
2397
+ Parameters
2398
+ ----------
2399
+ x : Any
2400
+ Object to test.
2401
+
2402
+ Returns
2403
+ -------
2404
+ bool
2405
+ ``True`` if *x* is a ``Guide`` instance or subclass.
2406
+ """
2407
+ return isinstance(x, Guide) or (isinstance(x, type) and issubclass(x, Guide))
2408
+
2409
+
2410
+ def is_guides(x: Any) -> bool:
2411
+ """Test whether *x* is a Guides container.
2412
+
2413
+ Parameters
2414
+ ----------
2415
+ x : Any
2416
+ Object to test.
2417
+
2418
+ Returns
2419
+ -------
2420
+ bool
2421
+ ``True`` if *x* is a ``Guides`` instance.
2422
+ """
2423
+ return isinstance(x, Guides)
2424
+
2425
+
2426
+ # ============================================================================
2427
+ # Guides container class
2428
+ # ============================================================================
2429
+
2430
+ class Guides:
2431
+ """Container for guide specifications by aesthetic.
2432
+
2433
+ A ``Guides`` object maps aesthetic names to guide objects (or strings
2434
+ that will be resolved to guide objects later). It manages the full
2435
+ lifecycle of merging, training, and assembling guides.
2436
+
2437
+ Parameters
2438
+ ----------
2439
+ guide_map : dict, optional
2440
+ Initial mapping of aesthetic name -> guide specification.
2441
+
2442
+ Attributes
2443
+ ----------
2444
+ guides : dict
2445
+ Aesthetic -> Guide mapping.
2446
+ params : list[dict]
2447
+ Parallel list of parameters for each guide.
2448
+ aesthetics : list[str]
2449
+ Parallel list of aesthetic names.
2450
+ """
2451
+
2452
+ def __init__(self, guide_map: Optional[Dict[str, Any]] = None) -> None:
2453
+ self.guides: Dict[str, Any] = guide_map or {}
2454
+ self.params: List[Dict[str, Any]] = []
2455
+ self.aesthetics: List[str] = []
2456
+ self._missing: GuideNone = guide_none()
2457
+
2458
+ def __repr__(self) -> str:
2459
+ keys = list(self.guides.keys())
2460
+ return f"<Guides: {keys}>"
2461
+
2462
+ # -- Setters -------------------------------------------------------------
2463
+
2464
+ def add(self, guides: Any) -> None:
2465
+ """Add new guides provided by the user.
2466
+
2467
+ Parameters
2468
+ ----------
2469
+ guides : dict or Guides
2470
+ New guide specifications to incorporate. Existing entries
2471
+ are kept as defaults.
2472
+ """
2473
+ if guides is None:
2474
+ return
2475
+ if isinstance(guides, Guides):
2476
+ guides = guides.guides
2477
+ self.guides = _defaults(guides, self.guides)
2478
+
2479
+ def update_params(self, params: List[Optional[Dict[str, Any]]]) -> None:
2480
+ """Update guide parameters in place.
2481
+
2482
+ Parameters
2483
+ ----------
2484
+ params : list of dict or None
2485
+ New parameter dicts. ``None`` entries replace the
2486
+ corresponding guide with ``guide_none()``.
2487
+ """
2488
+ if len(params) != len(self.params):
2489
+ cli_abort(
2490
+ f"Cannot update {len(self.params)} guide(s) with a list of "
2491
+ f"{len(params)} parameter(s)."
2492
+ )
2493
+ for i, p in enumerate(params):
2494
+ if p is None:
2495
+ self.guides[i] = self._missing
2496
+ else:
2497
+ self.params[i] = p
2498
+
2499
+ def subset_guides(self, mask: List[bool]) -> None:
2500
+ """Keep only guides where *mask* is ``True``.
2501
+
2502
+ Parameters
2503
+ ----------
2504
+ mask : list of bool
2505
+ Boolean mask parallel to ``self.guides``.
2506
+ """
2507
+ if isinstance(self.guides, dict):
2508
+ keys = list(self.guides.keys())
2509
+ self.guides = {k: v for k, keep in zip(keys, mask)
2510
+ for v in [self.guides[k]] if keep}
2511
+ elif isinstance(self.guides, list):
2512
+ self.guides = [g for g, keep in zip(self.guides, mask) if keep]
2513
+ self.params = [p for p, keep in zip(self.params, mask) if keep]
2514
+ self.aesthetics = [a for a, keep in zip(self.aesthetics, mask) if keep]
2515
+
2516
+ # -- Getters -------------------------------------------------------------
2517
+
2518
+ def get_guide(self, index: Union[int, str]) -> Optional[Any]:
2519
+ """Retrieve a guide by index or aesthetic name.
2520
+
2521
+ Parameters
2522
+ ----------
2523
+ index : int or str
2524
+ Index or aesthetic name.
2525
+
2526
+ Returns
2527
+ -------
2528
+ Guide or None
2529
+ The guide, or ``None`` if not found.
2530
+ """
2531
+ if isinstance(index, str):
2532
+ if isinstance(self.guides, dict):
2533
+ return self.guides.get(index)
2534
+ if index in self.aesthetics:
2535
+ idx = self.aesthetics.index(index)
2536
+ guides_list = list(self.guides.values()) if isinstance(
2537
+ self.guides, dict) else self.guides
2538
+ return guides_list[idx]
2539
+ return None
2540
+ guides_list = list(self.guides.values()) if isinstance(
2541
+ self.guides, dict) else self.guides
2542
+ if 0 <= index < len(guides_list):
2543
+ return guides_list[index]
2544
+ return None
2545
+
2546
+ def get_params(self, index: Union[int, str]) -> Optional[Dict[str, Any]]:
2547
+ """Retrieve parameters by index or aesthetic name.
2548
+
2549
+ Parameters
2550
+ ----------
2551
+ index : int or str
2552
+ Index or aesthetic name.
2553
+
2554
+ Returns
2555
+ -------
2556
+ dict or None
2557
+ Parameters, or ``None`` if not found.
2558
+ """
2559
+ if isinstance(index, str):
2560
+ if index in self.aesthetics:
2561
+ idx = self.aesthetics.index(index)
2562
+ return self.params[idx]
2563
+ return None
2564
+ if 0 <= index < len(self.params):
2565
+ return self.params[index]
2566
+ return None
2567
+
2568
+ # -- Building ------------------------------------------------------------
2569
+
2570
+ def setup(
2571
+ self,
2572
+ scales: List[Any],
2573
+ aesthetics: Optional[List[str]] = None,
2574
+ default: Any = None,
2575
+ missing: Any = None,
2576
+ ) -> "Guides":
2577
+ """Generate a guide for every scale-aesthetic pair.
2578
+
2579
+ Parameters
2580
+ ----------
2581
+ scales : list
2582
+ Scale objects.
2583
+ aesthetics : list of str, optional
2584
+ Aesthetic names parallel to *scales*.
2585
+ default : Guide, optional
2586
+ Default guide when none is specified.
2587
+ missing : Guide, optional
2588
+ Guide for unresolvable entries.
2589
+
2590
+ Returns
2591
+ -------
2592
+ Guides
2593
+ A new ``Guides`` instance populated with resolved guides.
2594
+ """
2595
+ if default is None:
2596
+ default = self._missing
2597
+ if missing is None:
2598
+ missing = self._missing
2599
+ if aesthetics is None:
2600
+ aesthetics = [getattr(s, "aesthetics", ["unknown"])[0] for s in scales]
2601
+
2602
+ new_guides: List[Any] = []
2603
+ for idx, scale in enumerate(scales):
2604
+ aes_name = aesthetics[idx]
2605
+ guide = self.guides.get(aes_name)
2606
+
2607
+ # Fallback hierarchy
2608
+ if guide is None:
2609
+ guide = getattr(scale, "guide", None)
2610
+ if guide is None or is_waiver(guide):
2611
+ guide = default
2612
+ if guide is None:
2613
+ guide = missing
2614
+
2615
+ # Resolve string names
2616
+ guide = _validate_guide(guide)
2617
+
2618
+ # Check compatibility
2619
+ if not isinstance(guide, GuideNone):
2620
+ scale_aes = getattr(scale, "aesthetics", [])
2621
+ if not any(a in ("x", "y") for a in scale_aes):
2622
+ scale_aes = list(scale_aes) + ["any"]
2623
+ available = getattr(guide, "available_aes", [])
2624
+ if not any(a in available for a in scale_aes):
2625
+ cli_warn(
2626
+ f"{snake_class(guide)} cannot be used for "
2627
+ f"{', '.join(scale_aes[:4])}."
2628
+ )
2629
+ guide = missing
2630
+
2631
+ new_guides.append(guide)
2632
+
2633
+ child = Guides()
2634
+ child.guides = new_guides
2635
+ child.params = [dict(getattr(g, "params", {})) for g in new_guides]
2636
+ child.aesthetics = list(aesthetics)
2637
+ return child
2638
+
2639
+ def train(self, scales: List[Any], labels: Dict[str, str]) -> None:
2640
+ """Train each guide on its paired scale.
2641
+
2642
+ Parameters
2643
+ ----------
2644
+ scales : list
2645
+ Scale objects, parallel to ``self.guides``.
2646
+ labels : dict
2647
+ Aesthetic -> label mapping.
2648
+ """
2649
+ guides_list = list(self.guides) if isinstance(self.guides, dict) else self.guides
2650
+ new_params: List[Optional[Dict[str, Any]]] = []
2651
+ for i, (guide, scale) in enumerate(zip(guides_list, scales)):
2652
+ aes = self.aesthetics[i] if i < len(self.aesthetics) else ""
2653
+ p = guide.train(
2654
+ params=dict(self.params[i]) if i < len(self.params) else {},
2655
+ scale=scale,
2656
+ aesthetic=aes,
2657
+ title=labels.get(aes),
2658
+ )
2659
+ new_params.append(p)
2660
+
2661
+ # Filter out None (dropped guides)
2662
+ keep = [p is not None for p in new_params]
2663
+ self.params = [p for p in new_params if p is not None]
2664
+ self.guides = [g for g, k in zip(guides_list, keep) if k]
2665
+ self.aesthetics = [a for a, k in zip(self.aesthetics, keep) if k]
2666
+
2667
+ # Drop GuideNone entries
2668
+ keep_none = [not isinstance(g, GuideNone) for g in self.guides]
2669
+ self.subset_guides(keep_none)
2670
+
2671
+ def merge(self) -> None:
2672
+ """Merge guides that encode the same information.
2673
+
2674
+ Groups guides by ``{order}_{hash}`` and merges groups with
2675
+ more than one member.
2676
+ """
2677
+ if len(self.guides) <= 1:
2678
+ return
2679
+
2680
+ guides_list = list(self.guides) if isinstance(self.guides, dict) else self.guides
2681
+
2682
+ # Build hash keys
2683
+ orders = [p.get("order", 0) for p in self.params]
2684
+ orders = [99 if o == 0 else o for o in orders]
2685
+ hashes = [p.get("hash", "") for p in self.params]
2686
+ keys = [f"{o:02d}_{h}" for o, h in zip(orders, hashes)]
2687
+
2688
+ # Group by key
2689
+ groups: Dict[str, List[int]] = {}
2690
+ for i, key in enumerate(keys):
2691
+ groups.setdefault(key, []).append(i)
2692
+
2693
+ merged_guides: List[Any] = []
2694
+ merged_params: List[Dict[str, Any]] = []
2695
+ merged_aes: List[str] = []
2696
+
2697
+ for key in sorted(groups.keys()):
2698
+ indices = groups[key]
2699
+ if len(indices) == 1:
2700
+ idx = indices[0]
2701
+ merged_guides.append(guides_list[idx])
2702
+ merged_params.append(self.params[idx])
2703
+ merged_aes.append(self.aesthetics[idx])
2704
+ else:
2705
+ # Sequentially merge
2706
+ result = {
2707
+ "guide": guides_list[indices[0]],
2708
+ "params": dict(self.params[indices[0]]),
2709
+ }
2710
+ for idx in indices[1:]:
2711
+ result = result["guide"].merge(
2712
+ result["params"],
2713
+ guides_list[idx],
2714
+ dict(self.params[idx]),
2715
+ )
2716
+ merged_guides.append(result["guide"])
2717
+ merged_params.append(result["params"])
2718
+ merged_aes.append(self.aesthetics[indices[0]])
2719
+
2720
+ self.guides = merged_guides
2721
+ self.params = merged_params
2722
+ self.aesthetics = merged_aes
2723
+
2724
+ def process_layers(
2725
+ self,
2726
+ layers: List[Any],
2727
+ data: Optional[List[Any]] = None,
2728
+ theme: Any = None,
2729
+ ) -> None:
2730
+ """Let guides extract information from layers.
2731
+
2732
+ Parameters
2733
+ ----------
2734
+ layers : list
2735
+ Plot layers.
2736
+ data : list, optional
2737
+ Layer data.
2738
+ theme : Theme, optional
2739
+ Plot theme.
2740
+ """
2741
+ guides_list = list(self.guides) if isinstance(self.guides, dict) else self.guides
2742
+ new_params = []
2743
+ for guide, params in zip(guides_list, self.params):
2744
+ new_params.append(guide.process_layers(params, layers, data, theme))
2745
+
2746
+ keep = [p is not None for p in new_params]
2747
+ self.params = [p for p in new_params if p is not None]
2748
+ self.guides = [g for g, k in zip(guides_list, keep) if k]
2749
+ self.aesthetics = [a for a, k in zip(self.aesthetics, keep) if k]
2750
+
2751
+ def build(
2752
+ self,
2753
+ scales: Any,
2754
+ layers: List[Any],
2755
+ labels: Dict[str, str],
2756
+ layer_data: Optional[List[Any]] = None,
2757
+ theme: Any = None,
2758
+ ) -> "Guides":
2759
+ """Full guide build pipeline.
2760
+
2761
+ Parameters
2762
+ ----------
2763
+ scales : ScalesList
2764
+ All scales from the plot.
2765
+ layers : list
2766
+ Plot layers.
2767
+ labels : dict
2768
+ Aesthetic -> label mapping.
2769
+ layer_data : list, optional
2770
+ Layer data.
2771
+ theme : Theme, optional
2772
+ Plot theme.
2773
+
2774
+ Returns
2775
+ -------
2776
+ Guides
2777
+ Built guides ready for assembly.
2778
+ """
2779
+ # Extract non-position scales
2780
+ if hasattr(scales, "non_position_scales"):
2781
+ scale_list = scales.non_position_scales()
2782
+ if hasattr(scale_list, "scales"):
2783
+ scale_list = scale_list.scales
2784
+ else:
2785
+ scale_list = scales if isinstance(scales, list) else []
2786
+
2787
+ if not scale_list:
2788
+ return Guides()
2789
+
2790
+ # Flatten aesthetics
2791
+ flat_scales = []
2792
+ flat_aes = []
2793
+ for s in scale_list:
2794
+ aes_names = getattr(s, "aesthetics", ["unknown"])
2795
+ for a in aes_names:
2796
+ flat_scales.append(s)
2797
+ flat_aes.append(a)
2798
+
2799
+ guides = self.setup(flat_scales, aesthetics=flat_aes)
2800
+ guides.train(flat_scales, labels)
2801
+
2802
+ if not guides.guides:
2803
+ return Guides()
2804
+
2805
+ guides.merge()
2806
+ guides.process_layers(layers, layer_data, theme)
2807
+ return guides
2808
+
2809
+ def draw(
2810
+ self,
2811
+ theme: Any,
2812
+ positions: List[str],
2813
+ direction: Optional[str] = None,
2814
+ ) -> List[Any]:
2815
+ """Render guides into grobs.
2816
+
2817
+ Parameters
2818
+ ----------
2819
+ theme : Theme
2820
+ Plot theme.
2821
+ positions : list of str
2822
+ Position for each guide.
2823
+ direction : str, optional
2824
+ Default direction.
2825
+
2826
+ Returns
2827
+ -------
2828
+ list
2829
+ Rendered grobs.
2830
+ """
2831
+ guides_list = list(self.guides) if isinstance(self.guides, dict) else self.guides
2832
+ directions = [direction or "vertical"] * len(positions)
2833
+ for i, pos in enumerate(positions):
2834
+ if direction is None and pos in ("top", "bottom"):
2835
+ directions[i] = "horizontal"
2836
+
2837
+ grobs = []
2838
+ for i, guide in enumerate(guides_list):
2839
+ g = guide.draw(
2840
+ theme=theme,
2841
+ position=positions[i],
2842
+ direction=directions[i],
2843
+ params=self.params[i] if i < len(self.params) else None,
2844
+ )
2845
+ grobs.append(g)
2846
+ return grobs
2847
+
2848
+ def assemble(self, theme: Any) -> Any:
2849
+ """Assemble all guides into positioned guide boxes.
2850
+
2851
+ Parameters
2852
+ ----------
2853
+ theme : Theme
2854
+ Plot theme.
2855
+
2856
+ Returns
2857
+ -------
2858
+ dict
2859
+ Mapping of position -> grob/gtable.
2860
+ """
2861
+ if not self.guides:
2862
+ return None
2863
+
2864
+ default_position = "right"
2865
+ if hasattr(theme, "__getitem__"):
2866
+ try:
2867
+ default_position = theme["legend.position"] or "right"
2868
+ except (KeyError, TypeError):
2869
+ pass
2870
+ elif hasattr(theme, "legend_position"):
2871
+ default_position = theme.legend_position or "right"
2872
+
2873
+ positions = []
2874
+ for p in self.params:
2875
+ pos = p.get("position") or default_position
2876
+ if is_waiver(pos):
2877
+ pos = default_position
2878
+ positions.append(pos)
2879
+
2880
+ grobs = self.draw(theme, positions)
2881
+ return {pos: grob for pos, grob in zip(positions, grobs)}
2882
+
2883
+
2884
+ # ============================================================================
2885
+ # guides() -- user-facing function
2886
+ # ============================================================================
2887
+
2888
+ def guides(**kwargs: Any) -> Optional[Guides]:
2889
+ """Set guides for each scale.
2890
+
2891
+ Parameters
2892
+ ----------
2893
+ **kwargs : str or Guide
2894
+ Mapping of aesthetic name to guide specification. Values can
2895
+ be guide objects, constructor calls, or strings like
2896
+ ``"legend"`` or ``"none"``.
2897
+
2898
+ Returns
2899
+ -------
2900
+ Guides or None
2901
+ A ``Guides`` container, or ``None`` if no guides were given.
2902
+
2903
+ Examples
2904
+ --------
2905
+ >>> guides(colour=guide_legend(), size="none")
2906
+ >>> guides(colour=guide_colourbar(nbin=50), shape=guide_legend(nrow=2))
2907
+ """
2908
+ if not kwargs:
2909
+ return None
2910
+
2911
+ # Standardise aesthetic names
2912
+ standardised: Dict[str, Any] = {}
2913
+ for k, v in kwargs.items():
2914
+ names = standardise_aes_names([k])
2915
+ new_key = names[0] if names else k
2916
+ if v is False:
2917
+ warnings.warn(
2918
+ "Setting a guide to `False` is deprecated. Use 'none' instead.",
2919
+ FutureWarning,
2920
+ stacklevel=2,
2921
+ )
2922
+ v = "none"
2923
+ standardised[new_key] = v
2924
+
2925
+ return Guides(standardised)