plotnine 0.14.5__py3-none-any.whl → 0.15.0.dev1__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 (61) hide show
  1. plotnine/__init__.py +31 -37
  2. plotnine/_mpl/gridspec.py +265 -0
  3. plotnine/_mpl/layout_manager/__init__.py +6 -0
  4. plotnine/_mpl/layout_manager/_engine.py +87 -0
  5. plotnine/_mpl/layout_manager/_layout_items.py +775 -0
  6. plotnine/_mpl/layout_manager/_layout_tree.py +625 -0
  7. plotnine/_mpl/layout_manager/_spaces.py +1007 -0
  8. plotnine/_mpl/utils.py +78 -10
  9. plotnine/_utils/__init__.py +4 -4
  10. plotnine/_utils/dev.py +45 -27
  11. plotnine/animation.py +1 -1
  12. plotnine/coords/coord_trans.py +1 -1
  13. plotnine/data/__init__.py +12 -8
  14. plotnine/doctools.py +1 -1
  15. plotnine/facets/facet.py +30 -39
  16. plotnine/facets/facet_grid.py +14 -6
  17. plotnine/facets/facet_wrap.py +3 -5
  18. plotnine/facets/strips.py +2 -7
  19. plotnine/geoms/geom_crossbar.py +2 -3
  20. plotnine/geoms/geom_path.py +1 -1
  21. plotnine/ggplot.py +94 -65
  22. plotnine/guides/guide.py +10 -8
  23. plotnine/guides/guide_colorbar.py +3 -3
  24. plotnine/guides/guide_legend.py +5 -5
  25. plotnine/guides/guides.py +3 -3
  26. plotnine/iapi.py +1 -0
  27. plotnine/labels.py +5 -0
  28. plotnine/options.py +14 -7
  29. plotnine/plot_composition/__init__.py +10 -0
  30. plotnine/plot_composition/_compose.py +427 -0
  31. plotnine/plot_composition/_plotspec.py +50 -0
  32. plotnine/plot_composition/_spacer.py +32 -0
  33. plotnine/positions/position_dodge.py +1 -1
  34. plotnine/positions/position_dodge2.py +1 -1
  35. plotnine/positions/position_stack.py +1 -2
  36. plotnine/qplot.py +1 -2
  37. plotnine/scales/__init__.py +0 -6
  38. plotnine/scales/scale.py +1 -1
  39. plotnine/stats/binning.py +1 -1
  40. plotnine/stats/smoothers.py +3 -5
  41. plotnine/stats/stat_density.py +1 -1
  42. plotnine/stats/stat_qq_line.py +1 -1
  43. plotnine/stats/stat_sina.py +1 -1
  44. plotnine/themes/elements/__init__.py +2 -0
  45. plotnine/themes/elements/element_text.py +34 -24
  46. plotnine/themes/elements/margin.py +73 -60
  47. plotnine/themes/targets.py +2 -0
  48. plotnine/themes/theme.py +13 -7
  49. plotnine/themes/theme_gray.py +27 -31
  50. plotnine/themes/theme_matplotlib.py +25 -28
  51. plotnine/themes/theme_seaborn.py +31 -34
  52. plotnine/themes/theme_void.py +17 -26
  53. plotnine/themes/themeable.py +286 -153
  54. {plotnine-0.14.5.dist-info → plotnine-0.15.0.dev1.dist-info}/METADATA +4 -3
  55. {plotnine-0.14.5.dist-info → plotnine-0.15.0.dev1.dist-info}/RECORD +58 -51
  56. {plotnine-0.14.5.dist-info → plotnine-0.15.0.dev1.dist-info}/WHEEL +1 -1
  57. plotnine/_mpl/_plot_side_space.py +0 -888
  58. plotnine/_mpl/_plotnine_tight_layout.py +0 -293
  59. plotnine/_mpl/layout_engine.py +0 -110
  60. {plotnine-0.14.5.dist-info → plotnine-0.15.0.dev1.dist-info/licenses}/LICENSE +0 -0
  61. {plotnine-0.14.5.dist-info → plotnine-0.15.0.dev1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,775 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from itertools import chain
5
+ from typing import TYPE_CHECKING, cast
6
+
7
+ from matplotlib.text import Text
8
+
9
+ from plotnine.exceptions import PlotnineError
10
+
11
+ from ..utils import (
12
+ bbox_in_figure_space,
13
+ get_subplotspecs,
14
+ rel_position,
15
+ tight_bbox_in_figure_space,
16
+ )
17
+
18
+ if TYPE_CHECKING:
19
+ from typing import (
20
+ Any,
21
+ Iterator,
22
+ Literal,
23
+ Sequence,
24
+ TypeAlias,
25
+ )
26
+
27
+ from matplotlib.artist import Artist
28
+ from matplotlib.axes import Axes
29
+ from matplotlib.axis import Tick
30
+ from matplotlib.backend_bases import RendererBase
31
+ from matplotlib.transforms import Bbox, Transform
32
+
33
+ from plotnine import ggplot
34
+ from plotnine._mpl.offsetbox import FlexibleAnchoredOffsetbox
35
+ from plotnine._mpl.text import StripText
36
+ from plotnine.iapi import legend_artists
37
+ from plotnine.themes.elements import margin as Margin
38
+ from plotnine.typing import StripPosition
39
+
40
+ from ._spaces import LayoutSpaces
41
+
42
+ AxesLocation: TypeAlias = Literal[
43
+ "all", "first_row", "last_row", "first_col", "last_col"
44
+ ]
45
+ TagLocation: TypeAlias = Literal["margin", "plot", "panel"]
46
+ TagPosition: TypeAlias = (
47
+ Literal[
48
+ "topleft",
49
+ "top",
50
+ "topright",
51
+ "left",
52
+ "right",
53
+ "bottomleft",
54
+ "bottom",
55
+ "bottomright",
56
+ ]
57
+ | tuple[float, float]
58
+ )
59
+
60
+
61
+ @dataclass
62
+ class Calc:
63
+ """
64
+ Calculate space taken up by an artist
65
+ """
66
+
67
+ # fig: Figure
68
+ # renderer: RendererBase
69
+ plot: ggplot
70
+
71
+ def __post_init__(self):
72
+ self.figure = self.plot.figure
73
+ self.renderer = cast("RendererBase", self.plot.figure._get_renderer()) # pyright: ignore
74
+
75
+ def bbox(self, artist: Artist) -> Bbox:
76
+ """
77
+ Bounding box of artist in figure coordinates
78
+ """
79
+ return bbox_in_figure_space(artist, self.figure, self.renderer)
80
+
81
+ def tight_bbox(self, artist: Artist) -> Bbox:
82
+ """
83
+ Bounding box of artist and its children in figure coordinates
84
+ """
85
+ return tight_bbox_in_figure_space(artist, self.figure, self.renderer)
86
+
87
+ def width(self, artist: Artist) -> float:
88
+ """
89
+ Width of artist in figure space
90
+ """
91
+ return self.bbox(artist).width
92
+
93
+ def tight_width(self, artist: Artist) -> float:
94
+ """
95
+ Width of artist and its children in figure space
96
+ """
97
+ return self.tight_bbox(artist).width
98
+
99
+ def height(self, artist: Artist) -> float:
100
+ """
101
+ Height of artist in figure space
102
+ """
103
+ return self.bbox(artist).height
104
+
105
+ def tight_height(self, artist: Artist) -> float:
106
+ """
107
+ Height of artist and its children in figure space
108
+ """
109
+ return self.tight_bbox(artist).height
110
+
111
+ def size(self, artist: Artist) -> tuple[float, float]:
112
+ """
113
+ (width, height) of artist in figure space
114
+ """
115
+ bbox = self.bbox(artist)
116
+ return (bbox.width, bbox.height)
117
+
118
+ def tight_size(self, artist: Artist) -> tuple[float, float]:
119
+ """
120
+ (width, height) of artist and its children in figure space
121
+ """
122
+ bbox = self.tight_bbox(artist)
123
+ return (bbox.width, bbox.height)
124
+
125
+ def left_x(self, artist: Artist) -> float:
126
+ """
127
+ x value of the left edge of the artist
128
+
129
+ ---
130
+ x |
131
+ ---
132
+ """
133
+ return self.bbox(artist).min[0]
134
+
135
+ def right_x(self, artist: Artist) -> float:
136
+ """
137
+ x value of the left edge of the artist
138
+
139
+ ---
140
+ | x
141
+ ---
142
+ """
143
+ return self.bbox(artist).max[0]
144
+
145
+ def top_y(self, artist: Artist) -> float:
146
+ """
147
+ y value of the top edge of the artist
148
+
149
+ -y-
150
+ | |
151
+ ---
152
+ """
153
+ return self.bbox(artist).max[1]
154
+
155
+ def bottom_y(self, artist: Artist) -> float:
156
+ """
157
+ y value of the bottom edge of the artist
158
+
159
+ ---
160
+ | |
161
+ -y-
162
+ """
163
+ return self.bbox(artist).min[1]
164
+
165
+ def max_width(self, artists: Sequence[Artist]) -> float:
166
+ """
167
+ Return the maximum width of list of artists
168
+ """
169
+ widths = [
170
+ bbox_in_figure_space(a, self.figure, self.renderer).width
171
+ for a in artists
172
+ ]
173
+ return max(widths) if len(widths) else 0
174
+
175
+ def max_height(self, artists: Sequence[Artist]) -> float:
176
+ """
177
+ Return the maximum height of list of artists
178
+ """
179
+ heights = [
180
+ bbox_in_figure_space(a, self.figure, self.renderer).height
181
+ for a in artists
182
+ ]
183
+ return max(heights) if len(heights) else 0
184
+
185
+
186
+ @dataclass
187
+ class LayoutItems:
188
+ """
189
+ Objects required to compute the layout
190
+ """
191
+
192
+ plot: ggplot
193
+
194
+ def __post_init__(self):
195
+ def get(name: str) -> Any:
196
+ """
197
+ Return themeable target or None
198
+ """
199
+ if self._is_blank(name):
200
+ return None
201
+ else:
202
+ t = getattr(self.plot.theme.targets, name)
203
+ if isinstance(t, Text) and t.get_text() == "":
204
+ return None
205
+ return t
206
+
207
+ self.calc = Calc(self.plot)
208
+
209
+ self.axis_title_x: Text | None = get("axis_title_x")
210
+ self.axis_title_y: Text | None = get("axis_title_y")
211
+
212
+ # # The legends references the structure that contains the
213
+ # # AnchoredOffsetboxes (groups of legends)
214
+ self.legends: legend_artists | None = get("legends")
215
+ self.plot_caption: Text | None = get("plot_caption")
216
+ self.plot_subtitle: Text | None = get("plot_subtitle")
217
+ self.plot_title: Text | None = get("plot_title")
218
+ self.plot_tag: Text | None = get("plot_tag")
219
+ self.strip_text_x: list[StripText] | None = get("strip_text_x")
220
+ self.strip_text_y: list[StripText] | None = get("strip_text_y")
221
+
222
+ def _is_blank(self, name: str) -> bool:
223
+ return self.plot.theme.T.is_blank(name)
224
+
225
+ def _filter_axes(self, location: AxesLocation = "all") -> list[Axes]:
226
+ """
227
+ Return subset of axes
228
+ """
229
+ axs = self.plot.axs
230
+
231
+ if location == "all":
232
+ return axs
233
+
234
+ # e.g. is_first_row, is_last_row, ..
235
+ pred_method = f"is_{location}"
236
+ return [
237
+ ax
238
+ for spec, ax in zip(get_subplotspecs(axs), axs)
239
+ if getattr(spec, pred_method)()
240
+ ]
241
+
242
+ def axis_text_x(self, ax: Axes) -> Iterator[Text]:
243
+ """
244
+ Return all x-axis labels for an axes that will be shown
245
+ """
246
+ major, minor = [], []
247
+
248
+ if not self._is_blank("axis_text_x"):
249
+ major = ax.xaxis.get_major_ticks()
250
+ minor = ax.xaxis.get_minor_ticks()
251
+
252
+ return (
253
+ tick.label1
254
+ for tick in chain(major, minor)
255
+ if _text_is_visible(tick.label1)
256
+ )
257
+
258
+ def axis_text_y(self, ax: Axes) -> Iterator[Text]:
259
+ """
260
+ Return all y-axis labels for an axes that will be shown
261
+ """
262
+ major, minor = [], []
263
+
264
+ if not self._is_blank("axis_text_y"):
265
+ major = ax.yaxis.get_major_ticks()
266
+ minor = ax.yaxis.get_minor_ticks()
267
+
268
+ return (
269
+ tick.label1
270
+ for tick in chain(major, minor)
271
+ if _text_is_visible(tick.label1)
272
+ )
273
+
274
+ def axis_ticks_x(self, ax: Axes) -> Iterator[Tick]:
275
+ """
276
+ Return all XTicks that will be shown
277
+ """
278
+ major, minor = [], []
279
+
280
+ if not self._is_blank("axis_ticks_major_x"):
281
+ major = ax.xaxis.get_major_ticks()
282
+
283
+ if not self._is_blank("axis_ticks_minor_x"):
284
+ minor = ax.xaxis.get_minor_ticks()
285
+
286
+ return chain(major, minor)
287
+
288
+ def axis_ticks_y(self, ax: Axes) -> Iterator[Tick]:
289
+ """
290
+ Return all YTicks that will be shown
291
+ """
292
+ major, minor = [], []
293
+
294
+ if not self._is_blank("axis_ticks_major_y"):
295
+ major = ax.yaxis.get_major_ticks()
296
+
297
+ if not self._is_blank("axis_ticks_minor_y"):
298
+ minor = ax.yaxis.get_minor_ticks()
299
+
300
+ return chain(major, minor)
301
+
302
+ def axis_text_x_margin(self, ax: Axes) -> Iterator[float]:
303
+ """
304
+ Return XTicks paddings
305
+ """
306
+ # In plotnine tick padding are specified as a margin to the
307
+ # the axis_text.
308
+ major, minor = [], []
309
+ if not self._is_blank("axis_text_x"):
310
+ h = self.plot.figure.bbox.height
311
+ major = [
312
+ (t.get_pad() or 0) / h for t in ax.xaxis.get_major_ticks()
313
+ ]
314
+ minor = [
315
+ (t.get_pad() or 0) / h for t in ax.xaxis.get_minor_ticks()
316
+ ]
317
+ return chain(major, minor)
318
+
319
+ def axis_text_y_margin(self, ax: Axes) -> Iterator[float]:
320
+ """
321
+ Return YTicks paddings
322
+ """
323
+ # In plotnine tick padding are specified as a margin to the
324
+ # the axis_text.
325
+ major, minor = [], []
326
+ if not self._is_blank("axis_text_y"):
327
+ w = self.plot.figure.bbox.width
328
+ major = [
329
+ (t.get_pad() or 0) / w for t in ax.yaxis.get_major_ticks()
330
+ ]
331
+ minor = [
332
+ (t.get_pad() or 0) / w for t in ax.yaxis.get_minor_ticks()
333
+ ]
334
+ return chain(major, minor)
335
+
336
+ def strip_text_x_height(self, position: StripPosition) -> float:
337
+ """
338
+ Height taken up by the top strips
339
+ """
340
+ if not self.strip_text_x:
341
+ return 0
342
+
343
+ artists = [
344
+ st.patch if st.patch.get_visible() else st
345
+ for st in self.strip_text_x
346
+ if st.patch.position == position
347
+ ]
348
+ return self.calc.max_height(artists)
349
+
350
+ def strip_text_y_width(self, position: StripPosition) -> float:
351
+ """
352
+ Width taken up by the right strips
353
+ """
354
+ if not self.strip_text_y:
355
+ return 0
356
+
357
+ artists = [
358
+ st.patch if st.patch.get_visible() else st
359
+ for st in self.strip_text_y
360
+ if st.patch.position == position
361
+ ]
362
+ return self.calc.max_width(artists)
363
+
364
+ def axis_ticks_x_max_height(self, location: AxesLocation) -> float:
365
+ """
366
+ Return maximum height[inches] of x ticks
367
+ """
368
+ heights = [
369
+ self.calc.tight_height(tick.tick1line)
370
+ for ax in self._filter_axes(location)
371
+ for tick in self.axis_ticks_x(ax)
372
+ ]
373
+ return max(heights) if len(heights) else 0
374
+
375
+ def axis_text_x_max_height(self, location: AxesLocation) -> float:
376
+ """
377
+ Return maximum height[inches] of x tick labels
378
+ """
379
+ heights = [
380
+ self.calc.tight_height(label) + pad
381
+ for ax in self._filter_axes(location)
382
+ for label, pad in zip(
383
+ self.axis_text_x(ax), self.axis_text_x_margin(ax)
384
+ )
385
+ ]
386
+ return max(heights) if len(heights) else 0
387
+
388
+ def axis_ticks_y_max_width(self, location: AxesLocation) -> float:
389
+ """
390
+ Return maximum width[inches] of y ticks
391
+ """
392
+ widths = [
393
+ self.calc.tight_width(tick.tick1line)
394
+ for ax in self._filter_axes(location)
395
+ for tick in self.axis_ticks_y(ax)
396
+ ]
397
+ return max(widths) if len(widths) else 0
398
+
399
+ def axis_text_y_max_width(self, location: AxesLocation) -> float:
400
+ """
401
+ Return maximum width[inches] of y tick labels
402
+ """
403
+ widths = [
404
+ self.calc.tight_width(label) + pad
405
+ for ax in self._filter_axes(location)
406
+ for label, pad in zip(
407
+ self.axis_text_y(ax), self.axis_text_y_margin(ax)
408
+ )
409
+ ]
410
+ return max(widths) if len(widths) else 0
411
+
412
+ def axis_text_y_top_protrusion(self, location: AxesLocation) -> float:
413
+ """
414
+ Return maximum height[inches] above the axes of y tick labels
415
+ """
416
+ extras = []
417
+ for ax in self._filter_axes(location):
418
+ ax_top_y = self.calc.top_y(ax)
419
+ for label in self.axis_text_y(ax):
420
+ label_top_y = self.calc.top_y(label)
421
+ extras.append(max(0, label_top_y - ax_top_y))
422
+
423
+ return max(extras) if len(extras) else 0
424
+
425
+ def axis_text_y_bottom_protrusion(self, location: AxesLocation) -> float:
426
+ """
427
+ Return maximum height[inches] below the axes of y tick labels
428
+ """
429
+ extras = []
430
+ for ax in self._filter_axes(location):
431
+ ax_bottom_y = self.calc.bottom_y(ax)
432
+ for label in self.axis_text_y(ax):
433
+ label_bottom_y = self.calc.bottom_y(label)
434
+ protrusion = abs(min(label_bottom_y - ax_bottom_y, 0))
435
+ extras.append(protrusion)
436
+
437
+ return max(extras) if len(extras) else 0
438
+
439
+ def axis_text_x_left_protrusion(self, location: AxesLocation) -> float:
440
+ """
441
+ Return maximum width[inches] of x tick labels to the left of the axes
442
+ """
443
+ extras = []
444
+ for ax in self._filter_axes(location):
445
+ ax_left_x = self.calc.left_x(ax)
446
+ for label in self.axis_text_x(ax):
447
+ label_left_x = self.calc.left_x(label)
448
+ protrusion = abs(min(label_left_x - ax_left_x, 0))
449
+ extras.append(protrusion)
450
+
451
+ return max(extras) if len(extras) else 0
452
+
453
+ def axis_text_x_right_protrusion(self, location: AxesLocation) -> float:
454
+ """
455
+ Return maximum width[inches] of x tick labels to the right of the axes
456
+ """
457
+ extras = []
458
+ for ax in self._filter_axes(location):
459
+ ax_right_x = self.calc.right_x(ax)
460
+ for label in self.axis_text_x(ax):
461
+ label_right_x = self.calc.right_x(label)
462
+ extras.append(max(0, label_right_x - ax_right_x))
463
+
464
+ return max(extras) if len(extras) else 0
465
+
466
+ def _adjust_positions(self, spaces: LayoutSpaces):
467
+ """
468
+ Set the x,y position of the artists around the panels
469
+ """
470
+ theme = self.plot.theme
471
+ plot_title_position = theme.getp("plot_title_position", "panel")
472
+ plot_caption_position = theme.getp("plot_caption_position", "panel")
473
+
474
+ if self.plot_tag:
475
+ set_plot_tag_position(self.plot_tag, spaces)
476
+
477
+ if self.plot_title:
478
+ ha = theme.getp(("plot_title", "ha"))
479
+ self.plot_title.set_y(spaces.t.y2("plot_title"))
480
+ horizontally_align_text(
481
+ self.plot_title, ha, spaces, plot_title_position
482
+ )
483
+
484
+ if self.plot_subtitle:
485
+ ha = theme.getp(("plot_subtitle", "ha"))
486
+ self.plot_subtitle.set_y(spaces.t.y2("plot_subtitle"))
487
+ horizontally_align_text(
488
+ self.plot_subtitle, ha, spaces, plot_title_position
489
+ )
490
+
491
+ if self.plot_caption:
492
+ ha = theme.getp(("plot_caption", "ha"), "right")
493
+ self.plot_caption.set_y(spaces.b.y1("plot_caption"))
494
+ horizontally_align_text(
495
+ self.plot_caption, ha, spaces, plot_caption_position
496
+ )
497
+
498
+ if self.axis_title_x:
499
+ ha = theme.getp(("axis_title_x", "ha"), "center")
500
+ self.axis_title_x.set_y(spaces.b.y1("axis_title_x"))
501
+ horizontally_align_text(self.axis_title_x, ha, spaces)
502
+
503
+ if self.axis_title_y:
504
+ va = theme.getp(("axis_title_y", "va"), "center")
505
+ self.axis_title_y.set_x(spaces.l.x1("axis_title_y"))
506
+ vertically_align_text(self.axis_title_y, va, spaces)
507
+
508
+ if self.legends:
509
+ set_legends_position(self.legends, spaces)
510
+
511
+
512
+ def _text_is_visible(text: Text) -> bool:
513
+ """
514
+ Return True if text is visible and is not empty
515
+ """
516
+ return text.get_visible() and text._text # type: ignore
517
+
518
+
519
+ def horizontally_align_text(
520
+ text: Text,
521
+ ha: str | float,
522
+ spaces: LayoutSpaces,
523
+ how: Literal["panel", "plot"] = "panel",
524
+ ):
525
+ """
526
+ Horizontal justification
527
+
528
+ Reinterpret horizontal alignment to be justification about the panels or
529
+ the plot (depending on the how parameter)
530
+ """
531
+ if isinstance(ha, str):
532
+ lookup = {
533
+ "left": 0.0,
534
+ "center": 0.5,
535
+ "right": 1.0,
536
+ }
537
+ rel = lookup[ha]
538
+ else:
539
+ rel = ha
540
+
541
+ if how == "panel":
542
+ left = spaces.l.left
543
+ right = spaces.r.right
544
+ else:
545
+ left = spaces.l.plot_left
546
+ right = spaces.r.plot_right
547
+
548
+ width = spaces.items.calc.width(text)
549
+ x = rel_position(rel, width, left, right)
550
+ text.set_x(x)
551
+ text.set_horizontalalignment("left")
552
+
553
+
554
+ def vertically_align_text(
555
+ text: Text,
556
+ va: str | float,
557
+ spaces: LayoutSpaces,
558
+ how: Literal["panel", "plot"] = "panel",
559
+ ):
560
+ """
561
+ Vertical justification
562
+
563
+ Reinterpret vertical alignment to be justification about the panels or
564
+ the plot (depending on the how parameter).
565
+ """
566
+ if isinstance(va, str):
567
+ lookup = {
568
+ "top": 1.0,
569
+ "center": 0.5,
570
+ "baseline": 0.5,
571
+ "center_baseline": 0.5,
572
+ "bottom": 0.0,
573
+ }
574
+ rel = lookup[va]
575
+ else:
576
+ rel = va
577
+
578
+ if how == "panel":
579
+ top = spaces.t.top
580
+ bottom = spaces.b.bottom
581
+ else:
582
+ top = spaces.t.plot_top
583
+ bottom = spaces.b.plot_bottom
584
+
585
+ height = spaces.items.calc.height(text)
586
+ y = rel_position(rel, height, bottom, top)
587
+ text.set_y(y)
588
+ text.set_verticalalignment("bottom")
589
+
590
+
591
+ def set_legends_position(legends: legend_artists, spaces: LayoutSpaces):
592
+ """
593
+ Place legend on the figure and justify is a required
594
+ """
595
+ panels_gs = spaces.plot.facet._panels_gridspec
596
+ params = panels_gs.get_subplot_params()
597
+ transFigure = spaces.plot.figure.transFigure
598
+
599
+ def set_position(
600
+ aob: FlexibleAnchoredOffsetbox,
601
+ anchor_point: tuple[float, float],
602
+ xy_loc: tuple[float, float],
603
+ transform: Transform = transFigure,
604
+ ):
605
+ """
606
+ Place box (by the anchor point) at given xy location
607
+
608
+ Parameters
609
+ ----------
610
+ aob :
611
+ Offsetbox to place
612
+ anchor_point :
613
+ Point on the Offsefbox.
614
+ xy_loc :
615
+ Point where to place the offsetbox.
616
+ transform :
617
+ Transformation
618
+ """
619
+ aob.xy_loc = xy_loc
620
+ aob.set_bbox_to_anchor(anchor_point, transform) # type: ignore
621
+
622
+ if legends.right:
623
+ y = rel_position(
624
+ legends.right.justification,
625
+ spaces.r._legend_height,
626
+ params.bottom,
627
+ params.top,
628
+ )
629
+ x = spaces.r.x2("legend")
630
+ set_position(legends.right.box, (x, y), (1, 0))
631
+
632
+ if legends.left:
633
+ y = rel_position(
634
+ legends.left.justification,
635
+ spaces.l._legend_height,
636
+ params.bottom,
637
+ params.top,
638
+ )
639
+ x = spaces.l.x1("legend")
640
+ set_position(legends.left.box, (x, y), (0, 0))
641
+
642
+ if legends.top:
643
+ x = rel_position(
644
+ legends.top.justification,
645
+ spaces.t._legend_width,
646
+ params.left,
647
+ params.right,
648
+ )
649
+ y = spaces.t.y2("legend")
650
+ set_position(legends.top.box, (x, y), (0, 1))
651
+
652
+ if legends.bottom:
653
+ x = rel_position(
654
+ legends.bottom.justification,
655
+ spaces.b._legend_width,
656
+ params.left,
657
+ params.right,
658
+ )
659
+ y = spaces.b.y1("legend")
660
+ set_position(legends.bottom.box, (x, y), (0, 0))
661
+
662
+ # Inside legends are placed using the panels coordinate system
663
+ if legends.inside:
664
+ transPanels = panels_gs.to_transform()
665
+ for l in legends.inside:
666
+ set_position(l.box, l.position, l.justification, transPanels)
667
+
668
+
669
+ def set_plot_tag_position(tag: Text, spaces: LayoutSpaces):
670
+ """
671
+ Set the postion of the plot_tag
672
+ """
673
+ theme = spaces.plot.theme
674
+ panels_gs = spaces.plot.facet._panels_gridspec
675
+ location: TagLocation = theme.getp("plot_tag_location")
676
+ position: TagPosition = theme.getp("plot_tag_position")
677
+ margin = theme.get_margin("plot_tag")
678
+
679
+ if location == "margin":
680
+ return set_plot_tag_position_in_margin(tag, spaces)
681
+
682
+ lookup: dict[str, tuple[float, float]] = {
683
+ "topleft": (0, 1),
684
+ "top": (0.5, 1),
685
+ "topright": (1, 1),
686
+ "left": (0, 0.5),
687
+ "right": (1, 0.5),
688
+ "bottomleft": (0, 0),
689
+ "bottom": (0.5, 0),
690
+ "bottomright": (1, 0),
691
+ }
692
+
693
+ if isinstance(position, str):
694
+ # Coordinates of the space in which to place the tag
695
+ if location == "plot":
696
+ (x1, y1), (x2, y2) = spaces.plot_area_coordinates
697
+ else:
698
+ (x1, y1), (x2, y2) = spaces.panel_area_coordinates
699
+
700
+ # Calculate the position when the tag has no margins
701
+ rel_x, rel_y = lookup[position]
702
+ width, height = spaces.items.calc.size(tag)
703
+ x = rel_position(rel_x, width, x1, x2)
704
+ y = rel_position(rel_y, height, y1, y2)
705
+
706
+ # Adjust the position to account for the margins
707
+ # When the units for the margin are in the figure coordinates,
708
+ # the adjustment is proportional to the size of the space.
709
+ # For points, inches and lines, the adjustment is absolute.
710
+ mx, my = _plot_tag_margin_adjustment(margin, position)
711
+ if margin.unit == "fig":
712
+ panel_width, panel_height = (x2 - x1), (y2 - y1)
713
+ else:
714
+ panel_width, panel_height = 1, 1
715
+
716
+ x += panel_width * mx
717
+ y += panel_height * my
718
+
719
+ position = (x, y)
720
+ tag.set_horizontalalignment("left")
721
+ tag.set_verticalalignment("bottom")
722
+ else:
723
+ if location == "panel":
724
+ transPanels = panels_gs.to_transform()
725
+ tag.set_transform(transPanels)
726
+
727
+ tag.set_position(position)
728
+
729
+
730
+ def set_plot_tag_position_in_margin(tag: Text, spaces: LayoutSpaces):
731
+ """
732
+ Place the tag in the margin around the plot
733
+ """
734
+ position: TagPosition = spaces.plot.theme.getp("plot_tag_position")
735
+ if not isinstance(position, str):
736
+ raise PlotnineError(
737
+ f"Cannot have plot_tag_location='margin' if "
738
+ f"plot_tag_position={position!r}."
739
+ )
740
+
741
+ tag.set_position(spaces.to_figure_space((0.5, 0.5)))
742
+ if "top" in position:
743
+ tag.set_y(spaces.t.y2("plot_tag"))
744
+ tag.set_verticalalignment("top")
745
+ if "bottom" in position:
746
+ tag.set_y(spaces.b.y1("plot_tag"))
747
+ tag.set_verticalalignment("bottom")
748
+ if "left" in position:
749
+ tag.set_x(spaces.l.x1("plot_tag"))
750
+ tag.set_horizontalalignment("left")
751
+ if "right" in position:
752
+ tag.set_x(spaces.r.x2("plot_tag"))
753
+ tag.set_horizontalalignment("right")
754
+
755
+
756
+ def _plot_tag_margin_adjustment(
757
+ margin: Margin, position: str
758
+ ) -> tuple[float, float]:
759
+ """
760
+ How to adjust the plot_tag to account for the margin
761
+ """
762
+ m = margin.fig
763
+ dx, dy = 0, 0
764
+
765
+ if "top" in position:
766
+ dy = -m.t
767
+ elif "bottom" in position:
768
+ dy = m.b
769
+
770
+ if "left" in position:
771
+ dx = m.l
772
+ elif "right" in position:
773
+ dx = -m.r
774
+
775
+ return (dx, dy)