plotnine 0.15.3__py3-none-any.whl → 0.16.0a1__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 (60) hide show
  1. plotnine/_mpl/gridspec.py +50 -6
  2. plotnine/_mpl/layout_manager/__init__.py +2 -5
  3. plotnine/_mpl/layout_manager/_composition_layout_items.py +98 -0
  4. plotnine/_mpl/layout_manager/_composition_side_space.py +461 -0
  5. plotnine/_mpl/layout_manager/_engine.py +19 -58
  6. plotnine/_mpl/layout_manager/_grid.py +94 -0
  7. plotnine/_mpl/layout_manager/_layout_tree.py +402 -817
  8. plotnine/_mpl/layout_manager/{_layout_items.py → _plot_layout_items.py} +55 -278
  9. plotnine/_mpl/layout_manager/{_spaces.py → _plot_side_space.py} +111 -291
  10. plotnine/_mpl/layout_manager/_side_space.py +176 -0
  11. plotnine/_mpl/utils.py +259 -1
  12. plotnine/_utils/__init__.py +23 -3
  13. plotnine/_utils/context.py +9 -13
  14. plotnine/_utils/dataclasses.py +24 -0
  15. plotnine/animation.py +13 -12
  16. plotnine/composition/__init__.py +6 -0
  17. plotnine/composition/_beside.py +13 -11
  18. plotnine/composition/_compose.py +263 -99
  19. plotnine/composition/_plot_annotation.py +75 -0
  20. plotnine/composition/_plot_layout.py +143 -0
  21. plotnine/composition/_plot_spacer.py +1 -1
  22. plotnine/composition/_stack.py +13 -11
  23. plotnine/composition/_types.py +28 -0
  24. plotnine/composition/_wrap.py +60 -0
  25. plotnine/facets/facet.py +9 -12
  26. plotnine/facets/facet_grid.py +2 -2
  27. plotnine/facets/facet_wrap.py +1 -1
  28. plotnine/geoms/geom.py +2 -2
  29. plotnine/geoms/geom_map.py +4 -5
  30. plotnine/geoms/geom_path.py +8 -7
  31. plotnine/geoms/geom_rug.py +6 -10
  32. plotnine/geoms/geom_text.py +5 -5
  33. plotnine/ggplot.py +63 -9
  34. plotnine/guides/guide.py +24 -6
  35. plotnine/guides/guide_colorbar.py +88 -46
  36. plotnine/guides/guide_legend.py +47 -20
  37. plotnine/guides/guides.py +2 -2
  38. plotnine/iapi.py +17 -1
  39. plotnine/scales/scale.py +1 -1
  40. plotnine/stats/binning.py +15 -43
  41. plotnine/stats/smoothers.py +7 -3
  42. plotnine/stats/stat.py +2 -2
  43. plotnine/stats/stat_density_2d.py +10 -6
  44. plotnine/stats/stat_pointdensity.py +8 -1
  45. plotnine/stats/stat_qq.py +5 -5
  46. plotnine/stats/stat_qq_line.py +6 -1
  47. plotnine/stats/stat_sina.py +19 -20
  48. plotnine/stats/stat_summary.py +4 -2
  49. plotnine/stats/stat_summary_bin.py +7 -1
  50. plotnine/themes/elements/element_line.py +2 -0
  51. plotnine/themes/elements/element_text.py +12 -1
  52. plotnine/themes/theme.py +18 -24
  53. plotnine/themes/themeable.py +17 -3
  54. plotnine/typing.py +6 -1
  55. {plotnine-0.15.3.dist-info → plotnine-0.16.0a1.dist-info}/METADATA +3 -3
  56. {plotnine-0.15.3.dist-info → plotnine-0.16.0a1.dist-info}/RECORD +59 -51
  57. {plotnine-0.15.3.dist-info → plotnine-0.16.0a1.dist-info}/WHEEL +1 -1
  58. plotnine/composition/_plotspec.py +0 -50
  59. {plotnine-0.15.3.dist-info → plotnine-0.16.0a1.dist-info}/licenses/LICENSE +0 -0
  60. {plotnine-0.15.3.dist-info → plotnine-0.16.0a1.dist-info}/top_level.txt +0 -0
plotnine/ggplot.py CHANGED
@@ -13,6 +13,7 @@ from typing import (
13
13
  Iterable,
14
14
  Optional,
15
15
  cast,
16
+ overload,
16
17
  )
17
18
  from warnings import warn
18
19
 
@@ -52,6 +53,7 @@ if TYPE_CHECKING:
52
53
 
53
54
  from plotnine import watermark
54
55
  from plotnine._mpl.gridspec import p9GridSpec
56
+ from plotnine._mpl.layout_manager._plot_side_space import PlotSideSpaces
55
57
  from plotnine.composition import Compose
56
58
  from plotnine.coords.coord import coord
57
59
  from plotnine.facets.facet import facet
@@ -104,6 +106,31 @@ class ggplot:
104
106
  figure: Figure
105
107
  axs: list[Axes]
106
108
  _gridspec: p9GridSpec
109
+ """
110
+ Gridspec (1x1) that contains the whole plot
111
+ """
112
+
113
+ _sub_gridspec: p9GridSpec
114
+ """
115
+ Gridspec (nxn) that contains the facet panels
116
+
117
+ -------------------------
118
+ | title |<----- ._gridspec
119
+ | subtitle |
120
+ | |
121
+ | ------------- |
122
+ | | | |<-------+------ ._sub_gridspec
123
+ | | | | |
124
+ | | | | legend |
125
+ | ------------- |
126
+ | axis_ticks |
127
+ | axis_text |
128
+ | axis_title |
129
+ | caption |
130
+ -------------------------
131
+ """
132
+
133
+ _sidespaces: PlotSideSpaces
107
134
 
108
135
  def __init__(
109
136
  self,
@@ -128,7 +155,7 @@ class ggplot:
128
155
  self.watermarks: list[watermark] = []
129
156
 
130
157
  # build artefacts
131
- self._build_objs = NS()
158
+ self._build_objs = NS(meta={})
132
159
 
133
160
  def __str__(self) -> str:
134
161
  """
@@ -230,10 +257,19 @@ class ggplot:
230
257
  other.__radd__(self)
231
258
  return self
232
259
 
260
+ @overload
233
261
  def __add__(
234
262
  self,
235
263
  rhs: PlotAddable | list[PlotAddable] | None,
236
- ) -> ggplot:
264
+ ) -> ggplot: ...
265
+
266
+ @overload
267
+ def __add__(self, rhs: ggplot) -> Compose: ...
268
+
269
+ def __add__(
270
+ self,
271
+ rhs: PlotAddable | list[PlotAddable] | None | ggplot,
272
+ ) -> ggplot | Compose:
237
273
  """
238
274
  Add to ggplot
239
275
 
@@ -243,7 +279,15 @@ class ggplot:
243
279
  Either an object that knows how to "radd"
244
280
  itself to a ggplot, or a list of such objects.
245
281
  """
282
+ from .composition import Compose
283
+
246
284
  self = deepcopy(self)
285
+
286
+ if isinstance(rhs, (ggplot, Compose)):
287
+ from .composition import Wrap
288
+
289
+ return Wrap([self, rhs])
290
+
247
291
  return self.__iadd__(rhs)
248
292
 
249
293
  def __or__(self, rhs: ggplot | Compose) -> Compose:
@@ -306,9 +350,14 @@ class ggplot:
306
350
  self._build()
307
351
 
308
352
  # setup
309
- self.axs = self.facet.setup(self)
353
+ self._sub_gridspec, self.axs = self.facet.setup(self)
310
354
  self.guides._setup(self)
311
- self.theme.setup(self)
355
+ self.theme._setup(
356
+ figure,
357
+ self.axs,
358
+ self.labels.title,
359
+ self.labels.subtitle,
360
+ )
312
361
 
313
362
  # Drawing
314
363
  self._draw_layers()
@@ -317,7 +366,7 @@ class ggplot:
317
366
  self.guides.draw()
318
367
  self._draw_figure_texts()
319
368
  self._draw_watermarks()
320
- self._draw_figure_background()
369
+ self._draw_plot_background()
321
370
 
322
371
  # Artist object theming
323
372
  self.theme.apply()
@@ -329,9 +378,7 @@ class ggplot:
329
378
  """
330
379
  Setup this instance for the building process
331
380
  """
332
- if not hasattr(self, "figure"):
333
- self._create_figure()
334
-
381
+ self._create_figure()
335
382
  self.labels.add_defaults(self.mapping.labels)
336
383
  return self.figure
337
384
 
@@ -339,6 +386,9 @@ class ggplot:
339
386
  """
340
387
  Create gridspec for the panels
341
388
  """
389
+ if hasattr(self, "figure"):
390
+ return
391
+
342
392
  import matplotlib.pyplot as plt
343
393
 
344
394
  from ._mpl.gridspec import p9GridSpec
@@ -536,7 +586,7 @@ class ggplot:
536
586
  for wm in self.watermarks:
537
587
  wm.draw(self.figure)
538
588
 
539
- def _draw_figure_background(self):
589
+ def _draw_plot_background(self):
540
590
  from matplotlib.patches import Rectangle
541
591
 
542
592
  rect = Rectangle((0, 0), 0, 0, facecolor="none", zorder=-1000)
@@ -577,6 +627,9 @@ class ggplot:
577
627
  This method has the same arguments as [](`~plotnine.ggplot.save`).
578
628
  Use it to get access to the figure that will be saved.
579
629
  """
630
+ if format is None and isinstance(filename, (str, Path)):
631
+ format = str(filename).split(".")[-1]
632
+
580
633
  fig_kwargs: Dict[str, Any] = {"format": format, **kwargs}
581
634
 
582
635
  if limitsize is None:
@@ -626,6 +679,7 @@ class ggplot:
626
679
  if dpi is not None:
627
680
  self.theme = self.theme + theme(dpi=dpi)
628
681
 
682
+ self._build_objs.meta["figure_format"] = format
629
683
  figure = self.draw(show=False)
630
684
  return mpl_save_view(figure, fig_kwargs)
631
685
 
plotnine/guides/guide.py CHANGED
@@ -11,13 +11,14 @@ from .._utils.registry import Register
11
11
  from ..themes.theme import theme as Theme
12
12
 
13
13
  if TYPE_CHECKING:
14
- from typing import Literal, Optional, TypeAlias
14
+ from typing import Literal, Optional, Sequence, TypeAlias
15
15
 
16
16
  import pandas as pd
17
17
  from matplotlib.offsetbox import PackerBase
18
18
  from typing_extensions import Self
19
19
 
20
20
  from plotnine import aes, guides
21
+ from plotnine.iapi import guide_text
21
22
  from plotnine.layer import Layers, layer
22
23
  from plotnine.scales.scale import scale
23
24
  from plotnine.typing import (
@@ -113,7 +114,7 @@ class guide(ABC, metaclass=Register):
113
114
  # guide theme has priority and its targets are tracked
114
115
  # independently.
115
116
  self.theme = guides.plot.theme + self.theme
116
- self.theme.setup(guides.plot)
117
+ self.theme._setup(guides.plot.figure)
117
118
  self.plot_layers = guides.plot.layers
118
119
  self.plot_mapping = guides.plot.mapping
119
120
  self.elements = self._elements_cls(self.theme, self)
@@ -139,6 +140,13 @@ class guide(ABC, metaclass=Register):
139
140
  just = cast("tuple[float, float]", just)
140
141
  return (pos, just)
141
142
 
143
+ @property
144
+ def num_breaks(self) -> int:
145
+ """
146
+ Number of breaks
147
+ """
148
+ return len(self.key)
149
+
142
150
  def train(
143
151
  self, scale: scale, aesthetic: Optional[str] = None
144
152
  ) -> Self | None:
@@ -148,6 +156,12 @@ class guide(ABC, metaclass=Register):
148
156
  Returns guide if training is successful
149
157
  """
150
158
 
159
+ def merge(self, other: Self) -> Self:
160
+ """
161
+ Merge with another guide
162
+ """
163
+ return self
164
+
151
165
  def draw(self) -> PackerBase:
152
166
  """
153
167
  Draw guide
@@ -176,6 +190,10 @@ class GuideElements:
176
190
  theme: Theme
177
191
  guide: guide
178
192
 
193
+ @cached_property
194
+ def text(self) -> guide_text:
195
+ raise NotImplementedError
196
+
179
197
  def __post_init__(self):
180
198
  self.guide_kind = type(self.guide).__name__.split("_")[-1]
181
199
  self._elements_cls = GuideElements
@@ -210,16 +228,16 @@ class GuideElements:
210
228
  )
211
229
 
212
230
  @cached_property
213
- def text_position(self) -> Side:
231
+ def text_positions(self) -> Sequence[Side]:
214
232
  raise NotImplementedError
215
233
 
216
234
  @cached_property
217
- def _text_margin(self) -> float:
235
+ def _text_margin(self) -> Sequence[float]:
218
236
  _margin = self.theme.getp(
219
237
  (f"legend_text_{self.guide_kind}", "margin")
220
238
  ).pt
221
- _loc = get_opposite_side(self.text_position)[0]
222
- return getattr(_margin, _loc)
239
+ locs = (get_opposite_side(p)[0] for p in self.text_positions)
240
+ return [getattr(_margin, loc) for loc in locs]
223
241
 
224
242
  @cached_property
225
243
  def title_position(self) -> Side:
@@ -11,6 +11,8 @@ import numpy as np
11
11
  import pandas as pd
12
12
  from mizani.bounds import rescale
13
13
 
14
+ from plotnine.iapi import guide_text
15
+
14
16
  from .._utils import get_opposite_side
15
17
  from ..exceptions import PlotnineError, PlotnineWarning
16
18
  from ..mapping.aes import rename_aesthetics
@@ -26,6 +28,7 @@ if TYPE_CHECKING:
26
28
  from matplotlib.text import Text
27
29
 
28
30
  from plotnine import theme
31
+ from plotnine.guides import guides
29
32
  from plotnine.scales.scale import scale
30
33
  from plotnine.typing import Side
31
34
 
@@ -48,7 +51,12 @@ class guide_colorbar(guide):
48
51
  """
49
52
 
50
53
  display: Literal["gradient", "rectangles", "raster"] = "gradient"
51
- """How to render the colorbar."""
54
+ """
55
+ How to render the colorbar
56
+
57
+ SVG figures will always use "rectangles" to create gradients. This has
58
+ better support across applications that render svg images.
59
+ """
52
60
 
53
61
  alpha: Optional[float] = None
54
62
  """
@@ -74,6 +82,12 @@ class guide_colorbar(guide):
74
82
  if self.nbin is None:
75
83
  self.nbin = 300 # if self.display == "gradient" else 300
76
84
 
85
+ def setup(self, guides: guides):
86
+ super().setup(guides)
87
+ # See: add_segmented_colorbar
88
+ if guides.plot._build_objs.meta.get("figure_format") == "svg":
89
+ self.display = "rectangles"
90
+
77
91
  def train(self, scale: scale, aesthetic=None):
78
92
  self.nbin = cast("int", self.nbin)
79
93
  self.title = cast("str", self.title)
@@ -121,12 +135,6 @@ class guide_colorbar(guide):
121
135
  self.hash = hashlib.sha256(info.encode("utf-8")).hexdigest()
122
136
  return self
123
137
 
124
- def merge(self, other):
125
- """
126
- Simply discards the other guide
127
- """
128
- return self
129
-
130
138
  def create_geoms(self):
131
139
  """
132
140
  Return self if colorbar will be drawn and None if not
@@ -177,6 +185,7 @@ class guide_colorbar(guide):
177
185
  nbars = len(self.bar)
178
186
  elements = self.elements
179
187
  raster = self.display == "raster"
188
+ alpha = self.alpha
180
189
 
181
190
  colors = self.bar["color"].tolist()
182
191
  labels = self.key["label"].tolist()
@@ -225,9 +234,9 @@ class guide_colorbar(guide):
225
234
 
226
235
  # colorbar
227
236
  if self.display == "rectangles":
228
- add_segmented_colorbar(auxbox, colors, elements)
237
+ add_segmented_colorbar(auxbox, colors, alpha, elements)
229
238
  else:
230
- add_gradient_colorbar(auxbox, colors, elements, raster)
239
+ add_gradient_colorbar(auxbox, colors, alpha, elements, raster)
231
240
 
232
241
  # ticks
233
242
  visible = slice(
@@ -270,6 +279,7 @@ guide_colourbar = guide_colorbar
270
279
  def add_gradient_colorbar(
271
280
  auxbox: AuxTransformBox,
272
281
  colors: Sequence[str],
282
+ alpha: float | None,
273
283
  elements: GuideElementsColorbar,
274
284
  raster: bool = False,
275
285
  ):
@@ -325,6 +335,7 @@ def add_gradient_colorbar(
325
335
  shading="gouraud",
326
336
  cmap=cmap,
327
337
  array=Z.ravel(),
338
+ alpha=alpha,
328
339
  rasterized=raster,
329
340
  )
330
341
  auxbox.add_artist(coll)
@@ -333,6 +344,7 @@ def add_gradient_colorbar(
333
344
  def add_segmented_colorbar(
334
345
  auxbox: AuxTransformBox,
335
346
  colors: Sequence[str],
347
+ alpha: float | None,
336
348
  elements: GuideElementsColorbar,
337
349
  ):
338
350
  """
@@ -341,6 +353,21 @@ def add_segmented_colorbar(
341
353
  from matplotlib.collections import PolyCollection
342
354
 
343
355
  nbreak = len(colors)
356
+ # Problem:
357
+ # 1. Webbrowsers do not properly render SVG with QuadMesh
358
+ # colorbars. Also when the QuadMesh is "rasterized",
359
+ # the colorbar is misplaced within the SVG (and pdfs!).
360
+ # So SVGs cannot use `add_gradient_colobar` at all.
361
+ # 2. Webbrowsers do not properly render SVG with PolyCollection
362
+ # colorbars when the adjacent rectangles that make up the
363
+ # colorbar touch each other precisely. The "bars" appear to
364
+ # be separated by lines.
365
+ #
366
+ # For a wayout, we overlap the bars. Overlapping creates artefacts
367
+ # when alpha < 1, but having a gradient + alpha is rare. And, we can
368
+ # minimise apparent artefacts by using a large overlap_factor.
369
+ # A value of 2 gives the best results in the rare case should alpha < 1.
370
+ overlap_factor = 2
344
371
  if elements.is_vertical:
345
372
  colorbar_height = elements.key_height
346
373
  colorbar_width = elements.key_width
@@ -351,6 +378,8 @@ def add_segmented_colorbar(
351
378
  for i in range(nbreak):
352
379
  y1 = i * linewidth
353
380
  y2 = y1 + linewidth
381
+ if i > 1:
382
+ y1 -= linewidth * overlap_factor
354
383
  verts.append(((x1, y1), (x1, y2), (x2, y2), (x2, y1)))
355
384
  else:
356
385
  colorbar_width = elements.key_height
@@ -362,12 +391,15 @@ def add_segmented_colorbar(
362
391
  for i in range(nbreak):
363
392
  x1 = i * linewidth
364
393
  x2 = x1 + linewidth
394
+ if i > 1:
395
+ x1 -= linewidth * overlap_factor
365
396
  verts.append(((x1, y1), (x1, y2), (x2, y2), (x2, y1)))
366
397
 
367
398
  coll = PolyCollection(
368
399
  verts,
369
400
  facecolors=colors,
370
401
  linewidth=0,
402
+ alpha=alpha,
371
403
  antialiased=False,
372
404
  )
373
405
  auxbox.add_artist(coll)
@@ -417,28 +449,29 @@ def add_labels(
417
449
  """
418
450
  from matplotlib.text import Text
419
451
 
420
- n = len(labels)
421
- sep = elements.text.margin
452
+ seps = elements.text.margins
422
453
  texts: list[Text] = []
423
- props = {"ha": elements.text.ha, "va": elements.text.va}
454
+ has = elements.text.has
455
+ vas = elements.text.vas
456
+ width = elements.key_width
424
457
 
425
458
  # The horizontal and vertical alignments are set in the theme
426
459
  # or dynamically calculates in GuideElements and added to the
427
460
  # themeable properties dict
428
461
  if elements.is_vertical:
429
- if elements.text_position == "right":
430
- xs = [elements.key_width + sep] * n
431
- else:
432
- xs = [-sep] * n
462
+ xs = [
463
+ width + sep if side == "right" else -sep
464
+ for side, sep in zip(elements.text_positions, seps)
465
+ ]
433
466
  else:
434
467
  xs = ys
435
- if elements.text_position == "bottom":
436
- ys = [-sep] * n
437
- else:
438
- ys = [elements.key_width + sep] * n
468
+ ys = [
469
+ -sep if side == "bottom" else width + sep
470
+ for side, sep in zip(elements.text_positions, seps)
471
+ ]
439
472
 
440
- for x, y, s in zip(xs, ys, labels):
441
- t = Text(x, y, s, **props)
473
+ for x, y, s, ha, va in zip(xs, ys, labels, has, vas):
474
+ t = Text(x, y, s, ha=ha, va=va)
442
475
  auxbox.add_artist(t)
443
476
  texts.append(t)
444
477
 
@@ -474,43 +507,52 @@ class GuideElementsColorbar(GuideElements):
474
507
  ha = self.theme.getp(("legend_text_colorbar", "ha"))
475
508
  va = self.theme.getp(("legend_text_colorbar", "va"))
476
509
  is_blank = self.theme.T.is_blank("legend_text_colorbar")
510
+ n = self.guide.num_breaks
477
511
 
478
512
  # Default text alignment depends on the direction of the
479
513
  # colorbar
480
- _loc = get_opposite_side(self.text_position)
514
+ centers = ("center",) * n
515
+ has = (ha,) * n if isinstance(ha, str) else ha
516
+ vas = (va,) * n if isinstance(va, str) else va
517
+ opposite_sides = [get_opposite_side(s) for s in self.text_positions]
481
518
  if self.is_vertical:
482
- ha = ha or _loc
483
- va = va or "center"
519
+ has = has or opposite_sides
520
+ vas = vas or centers
484
521
  else:
485
- va = va or _loc
486
- ha = ha or "center"
487
-
488
- return NS(
489
- margin=self._text_margin,
490
- align=None,
522
+ vas = vas or opposite_sides
523
+ has = has or centers
524
+ return guide_text(
525
+ self._text_margin,
526
+ aligns=centers,
491
527
  fontsize=size,
492
- ha=ha,
493
- va=va,
528
+ has=has, # pyright: ignore[reportArgumentType]
529
+ vas=vas, # pyright: ignore[reportArgumentType]
494
530
  is_blank=is_blank,
495
531
  )
496
532
 
497
533
  @cached_property
498
- def text_position(self) -> Side:
499
- if not (position := self.theme.getp("legend_text_position")):
534
+ def text_positions(self) -> Sequence[Side]:
535
+ if not (user_position := self.theme.getp("legend_text_position")):
500
536
  position = "right" if self.is_vertical else "bottom"
537
+ return (position,) * self.guide.num_breaks
501
538
 
502
- if self.is_vertical and position not in ("right", "left"):
503
- msg = (
504
- "The text position for a vertical legend must be "
505
- "either left or right."
506
- )
507
- raise PlotnineError(msg)
508
- elif self.is_horizontal and position not in ("bottom", "top"):
509
- msg = (
510
- "The text position for a horizonta legend must be "
511
- "either top or bottom."
539
+ alternate = {"left-right", "right-left", "bottom-top", "top-bottom"}
540
+ if user_position in alternate:
541
+ tup = user_position.split("-")
542
+ return [tup[i % 2] for i in range(self.guide.num_breaks)]
543
+
544
+ position = cast("Side | Sequence[Side]", user_position)
545
+
546
+ if isinstance(position, str):
547
+ position = (position,) * self.guide.num_breaks
548
+
549
+ valid = {"right", "left"} if self.is_vertical else {"bottom", "top"}
550
+ if any(p for p in position if p not in valid):
551
+ raise PlotnineError(
552
+ "The text position for a horizontal legend must be "
553
+ f"either one of {valid!r}. I got {user_position!r}."
512
554
  )
513
- raise PlotnineError(msg)
555
+
514
556
  return position
515
557
 
516
558
  @cached_property
@@ -5,20 +5,21 @@ from contextlib import suppress
5
5
  from dataclasses import dataclass, field
6
6
  from functools import cached_property
7
7
  from itertools import islice
8
- from types import SimpleNamespace as NS
9
8
  from typing import TYPE_CHECKING, cast
10
9
  from warnings import warn
11
10
 
12
11
  import numpy as np
13
12
  import pandas as pd
14
13
 
14
+ from plotnine.iapi import guide_text
15
+
15
16
  from .._utils import remove_missing
16
17
  from ..exceptions import PlotnineError, PlotnineWarning
17
18
  from ..mapping.aes import rename_aesthetics
18
19
  from .guide import GuideElements, guide
19
20
 
20
21
  if TYPE_CHECKING:
21
- from typing import Any, Optional
22
+ from typing import Any, Optional, Sequence
22
23
 
23
24
  from matplotlib.artist import Artist
24
25
  from matplotlib.offsetbox import PackerBase
@@ -209,7 +210,7 @@ class guide_legend(guide):
209
210
  self, elements: GuideElementsLegend
210
211
  ) -> tuple[int, int]:
211
212
  nrow, ncol = self.nrow, self.ncol
212
- nbreak = len(self.key)
213
+ nbreak = self.num_breaks
213
214
 
214
215
  if nrow and ncol:
215
216
  if nrow * ncol < nbreak:
@@ -248,7 +249,7 @@ class guide_legend(guide):
248
249
 
249
250
  obverse = slice(0, None)
250
251
  reverse = slice(None, None, -1)
251
- nbreak = len(self.key)
252
+ nbreak = self.num_breaks
252
253
  targets = self.theme.targets
253
254
  keys_order = reverse if self.reverse else obverse
254
255
  elements = self.elements
@@ -259,8 +260,12 @@ class guide_legend(guide):
259
260
  targets.legend_title = title_box._text # type: ignore
260
261
 
261
262
  # labels
262
- props = {"ha": elements.text.ha, "va": elements.text.va}
263
- labels = [TextArea(s, textprops=props) for s in self.key["label"]]
263
+ has = elements.text.has
264
+ vas = elements.text.vas
265
+ labels = [
266
+ TextArea(s, textprops={"ha": ha, "va": va})
267
+ for s, ha, va in zip(self.key["label"], has, vas)
268
+ ]
264
269
  _texts = [l._text for l in labels] # type: ignore
265
270
  targets.legend_text_legend = _texts
266
271
 
@@ -287,18 +292,28 @@ class guide_legend(guide):
287
292
  "bottom": (VPacker, reverse),
288
293
  "top": (VPacker, obverse),
289
294
  }
290
- packer, slc = lookup[elements.text_position]
295
+
291
296
  if self.elements.text.is_blank:
292
297
  key_boxes = [d for d in drawings][keys_order]
293
298
  else:
299
+ packers, slices = [], []
300
+ for side in elements.text_positions:
301
+ tup = lookup[side]
302
+ packers.append(tup[0])
303
+ slices.append(tup[1])
304
+
305
+ seps = elements.text.margins
306
+ aligns = elements.text.aligns
294
307
  key_boxes = [
295
308
  packer(
296
309
  children=[l, d][slc],
297
- sep=elements.text.margin,
298
- align=elements.text.align,
310
+ sep=sep,
311
+ align=align,
299
312
  pad=0,
300
313
  )
301
- for d, l in zip(drawings, labels)
314
+ for d, l, packer, slc, sep, align in zip(
315
+ drawings, labels, packers, slices, seps, aligns
316
+ )
302
317
  ][keys_order]
303
318
 
304
319
  # Put the entries together in rows or columns
@@ -364,26 +379,38 @@ class GuideElementsLegend(GuideElements):
364
379
  ha = self.theme.getp(("legend_text_legend", "ha"), "center")
365
380
  va = self.theme.getp(("legend_text_legend", "va"), "center")
366
381
  is_blank = self.theme.T.is_blank("legend_text_legend")
382
+ n = self.guide.num_breaks
367
383
 
368
384
  # The original ha & va values are used by the HPacker/VPacker
369
385
  # to align the TextArea with the DrawingArea.
370
386
  # We set ha & va to values that combine best with the aligning
371
387
  # for the text area.
372
- align = va if self.text_position in {"left", "right"} else ha
373
- return NS(
374
- margin=self._text_margin,
375
- align=align,
388
+ has = (ha,) * n if isinstance(ha, str) else ha
389
+ vas = (va,) * n if isinstance(va, str) else va
390
+ aligns = [
391
+ va if side in ("right", "left") else ha
392
+ for side, ha, va in zip(self.text_positions, has, vas)
393
+ ]
394
+ return guide_text(
395
+ margins=self._text_margin,
396
+ aligns=aligns, # pyright: ignore[reportArgumentType]
376
397
  fontsize=size,
377
- ha="center",
378
- va="baseline",
398
+ has=("center",) * n,
399
+ vas=("baseline",) * n,
379
400
  is_blank=is_blank,
380
401
  )
381
402
 
382
403
  @cached_property
383
- def text_position(self) -> Side:
384
- if not (pos := self.theme.getp("legend_text_position")):
385
- pos = "right"
386
- return pos
404
+ def text_positions(self) -> Sequence[Side]:
405
+ if not (position := self.theme.getp("legend_text_position")):
406
+ return ("right",) * self.guide.num_breaks
407
+
408
+ position = cast("Side | Sequence[Side]", position)
409
+
410
+ if isinstance(position, str):
411
+ position = (position,) * self.guide.num_breaks
412
+
413
+ return position
387
414
 
388
415
  @cached_property
389
416
  def key_spacing_x(self) -> float:
plotnine/guides/guides.py CHANGED
@@ -31,12 +31,12 @@ if TYPE_CHECKING:
31
31
  from plotnine.scales.scale import scale
32
32
  from plotnine.scales.scales import Scales
33
33
  from plotnine.typing import (
34
+ Justification,
34
35
  LegendPosition,
35
36
  NoGuide,
36
37
  Orientation,
37
38
  ScaledAestheticsName,
38
39
  Side,
39
- TextJustification,
40
40
  )
41
41
 
42
42
  LegendOrColorbar: TypeAlias = (
@@ -437,7 +437,7 @@ class GuidesElements:
437
437
  return ensure_xy_location(just)
438
438
 
439
439
  @cached_property
440
- def box_just(self) -> TextJustification:
440
+ def box_just(self) -> Justification | Literal["baseline"]:
441
441
  if not (box_just := self.theme.getp("legend_box_just")):
442
442
  box_just = (
443
443
  "left" if self.position in {"left", "right"} else "right"