plotnine 0.14.5__py3-none-any.whl → 0.15.0a2__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 (92) 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 +957 -0
  6. plotnine/_mpl/layout_manager/_layout_tree.py +905 -0
  7. plotnine/_mpl/layout_manager/_spaces.py +1154 -0
  8. plotnine/_mpl/patches.py +70 -34
  9. plotnine/_mpl/text.py +159 -37
  10. plotnine/_mpl/utils.py +78 -10
  11. plotnine/_utils/__init__.py +35 -9
  12. plotnine/_utils/dev.py +45 -27
  13. plotnine/_utils/yippie.py +115 -0
  14. plotnine/animation.py +1 -1
  15. plotnine/coords/coord.py +3 -3
  16. plotnine/coords/coord_trans.py +1 -1
  17. plotnine/data/__init__.py +43 -8
  18. plotnine/data/anscombe-quartet.csv +45 -0
  19. plotnine/doctools.py +2 -2
  20. plotnine/facets/facet.py +34 -43
  21. plotnine/facets/facet_grid.py +14 -6
  22. plotnine/facets/facet_wrap.py +3 -5
  23. plotnine/facets/strips.py +20 -33
  24. plotnine/geoms/annotate.py +3 -3
  25. plotnine/geoms/annotation_logticks.py +2 -0
  26. plotnine/geoms/annotation_stripes.py +2 -0
  27. plotnine/geoms/geom.py +3 -3
  28. plotnine/geoms/geom_bar.py +10 -2
  29. plotnine/geoms/geom_col.py +6 -0
  30. plotnine/geoms/geom_crossbar.py +2 -3
  31. plotnine/geoms/geom_path.py +2 -2
  32. plotnine/geoms/geom_violin.py +24 -7
  33. plotnine/ggplot.py +95 -66
  34. plotnine/guides/guide.py +19 -20
  35. plotnine/guides/guide_colorbar.py +6 -6
  36. plotnine/guides/guide_legend.py +15 -16
  37. plotnine/guides/guides.py +8 -8
  38. plotnine/helpers.py +49 -0
  39. plotnine/iapi.py +33 -7
  40. plotnine/labels.py +8 -3
  41. plotnine/layer.py +4 -4
  42. plotnine/mapping/_env.py +2 -2
  43. plotnine/mapping/_eval_environment.py +85 -0
  44. plotnine/mapping/aes.py +14 -30
  45. plotnine/mapping/evaluation.py +7 -65
  46. plotnine/options.py +14 -7
  47. plotnine/plot_composition/__init__.py +10 -0
  48. plotnine/plot_composition/_compose.py +462 -0
  49. plotnine/plot_composition/_plotspec.py +50 -0
  50. plotnine/plot_composition/_spacer.py +32 -0
  51. plotnine/positions/position_dodge.py +1 -1
  52. plotnine/positions/position_dodge2.py +1 -1
  53. plotnine/positions/position_stack.py +1 -2
  54. plotnine/qplot.py +1 -2
  55. plotnine/scales/__init__.py +0 -6
  56. plotnine/scales/limits.py +7 -7
  57. plotnine/scales/scale.py +4 -4
  58. plotnine/scales/scale_continuous.py +2 -1
  59. plotnine/scales/scale_identity.py +10 -2
  60. plotnine/scales/scale_manual.py +6 -2
  61. plotnine/stats/binning.py +5 -2
  62. plotnine/stats/smoothers.py +3 -5
  63. plotnine/stats/stat.py +3 -3
  64. plotnine/stats/stat_bindot.py +1 -3
  65. plotnine/stats/stat_density.py +2 -2
  66. plotnine/stats/stat_qq_line.py +1 -1
  67. plotnine/stats/stat_sina.py +34 -1
  68. plotnine/themes/elements/__init__.py +3 -0
  69. plotnine/themes/elements/element_text.py +35 -24
  70. plotnine/themes/elements/margin.py +137 -61
  71. plotnine/themes/targets.py +3 -1
  72. plotnine/themes/theme.py +21 -7
  73. plotnine/themes/theme_538.py +0 -1
  74. plotnine/themes/theme_bw.py +0 -1
  75. plotnine/themes/theme_dark.py +0 -1
  76. plotnine/themes/theme_gray.py +32 -34
  77. plotnine/themes/theme_light.py +1 -1
  78. plotnine/themes/theme_matplotlib.py +28 -31
  79. plotnine/themes/theme_seaborn.py +36 -36
  80. plotnine/themes/theme_void.py +25 -27
  81. plotnine/themes/theme_xkcd.py +0 -1
  82. plotnine/themes/themeable.py +369 -169
  83. plotnine/typing.py +3 -3
  84. plotnine/watermark.py +3 -3
  85. {plotnine-0.14.5.dist-info → plotnine-0.15.0a2.dist-info}/METADATA +8 -5
  86. {plotnine-0.14.5.dist-info → plotnine-0.15.0a2.dist-info}/RECORD +89 -78
  87. {plotnine-0.14.5.dist-info → plotnine-0.15.0a2.dist-info}/WHEEL +1 -1
  88. plotnine/_mpl/_plot_side_space.py +0 -888
  89. plotnine/_mpl/_plotnine_tight_layout.py +0 -293
  90. plotnine/_mpl/layout_engine.py +0 -110
  91. {plotnine-0.14.5.dist-info → plotnine-0.15.0a2.dist-info/licenses}/LICENSE +0 -0
  92. {plotnine-0.14.5.dist-info → plotnine-0.15.0a2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,957 @@
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._mpl.patches import StripTextPatch
10
+ from plotnine._utils import ha_as_float, va_as_float
11
+ from plotnine.exceptions import PlotnineError
12
+
13
+ from ..utils import (
14
+ bbox_in_figure_space,
15
+ get_subplotspecs,
16
+ rel_position,
17
+ tight_bbox_in_figure_space,
18
+ )
19
+
20
+ if TYPE_CHECKING:
21
+ from typing import (
22
+ Any,
23
+ Iterator,
24
+ Literal,
25
+ Sequence,
26
+ TypeAlias,
27
+ )
28
+
29
+ from matplotlib.artist import Artist
30
+ from matplotlib.axes import Axes
31
+ from matplotlib.axis import Tick
32
+ from matplotlib.backend_bases import RendererBase
33
+ from matplotlib.transforms import Bbox, Transform
34
+
35
+ from plotnine import ggplot
36
+ from plotnine._mpl.offsetbox import FlexibleAnchoredOffsetbox
37
+ from plotnine._mpl.text import StripText
38
+ from plotnine.iapi import legend_artists
39
+ from plotnine.themes.elements import margin as Margin
40
+ from plotnine.typing import (
41
+ HorizontalJustification,
42
+ StripPosition,
43
+ VerticalJustification,
44
+ )
45
+
46
+ from ._spaces import LayoutSpaces
47
+
48
+ AxesLocation: TypeAlias = Literal[
49
+ "all", "first_row", "last_row", "first_col", "last_col"
50
+ ]
51
+ TagLocation: TypeAlias = Literal["margin", "plot", "panel"]
52
+ TagPosition: TypeAlias = (
53
+ Literal[
54
+ "topleft",
55
+ "top",
56
+ "topright",
57
+ "left",
58
+ "right",
59
+ "bottomleft",
60
+ "bottom",
61
+ "bottomright",
62
+ ]
63
+ | tuple[float, float]
64
+ )
65
+
66
+
67
+ @dataclass
68
+ class Calc:
69
+ """
70
+ Calculate space taken up by an artist
71
+ """
72
+
73
+ # fig: Figure
74
+ # renderer: RendererBase
75
+ plot: ggplot
76
+
77
+ def __post_init__(self):
78
+ self.figure = self.plot.figure
79
+ self.renderer = cast("RendererBase", self.plot.figure._get_renderer()) # pyright: ignore
80
+
81
+ def bbox(self, artist: Artist) -> Bbox:
82
+ """
83
+ Bounding box of artist in figure coordinates
84
+ """
85
+ return bbox_in_figure_space(artist, self.figure, self.renderer)
86
+
87
+ def tight_bbox(self, artist: Artist) -> Bbox:
88
+ """
89
+ Bounding box of artist and its children in figure coordinates
90
+ """
91
+ return tight_bbox_in_figure_space(artist, self.figure, self.renderer)
92
+
93
+ def width(self, artist: Artist) -> float:
94
+ """
95
+ Width of artist in figure space
96
+ """
97
+ return self.bbox(artist).width
98
+
99
+ def tight_width(self, artist: Artist) -> float:
100
+ """
101
+ Width of artist and its children in figure space
102
+ """
103
+ return self.tight_bbox(artist).width
104
+
105
+ def height(self, artist: Artist) -> float:
106
+ """
107
+ Height of artist in figure space
108
+ """
109
+ return self.bbox(artist).height
110
+
111
+ def tight_height(self, artist: Artist) -> float:
112
+ """
113
+ Height of artist and its children in figure space
114
+ """
115
+ return self.tight_bbox(artist).height
116
+
117
+ def size(self, artist: Artist) -> tuple[float, float]:
118
+ """
119
+ (width, height) of artist in figure space
120
+ """
121
+ bbox = self.bbox(artist)
122
+ return (bbox.width, bbox.height)
123
+
124
+ def tight_size(self, artist: Artist) -> tuple[float, float]:
125
+ """
126
+ (width, height) of artist and its children in figure space
127
+ """
128
+ bbox = self.tight_bbox(artist)
129
+ return (bbox.width, bbox.height)
130
+
131
+ def left_x(self, artist: Artist) -> float:
132
+ """
133
+ x value of the left edge of the artist
134
+
135
+ ---
136
+ x |
137
+ ---
138
+ """
139
+ return self.bbox(artist).min[0]
140
+
141
+ def right_x(self, artist: Artist) -> float:
142
+ """
143
+ x value of the left edge of the artist
144
+
145
+ ---
146
+ | x
147
+ ---
148
+ """
149
+ return self.bbox(artist).max[0]
150
+
151
+ def top_y(self, artist: Artist) -> float:
152
+ """
153
+ y value of the top edge of the artist
154
+
155
+ -y-
156
+ | |
157
+ ---
158
+ """
159
+ return self.bbox(artist).max[1]
160
+
161
+ def bottom_y(self, artist: Artist) -> float:
162
+ """
163
+ y value of the bottom edge of the artist
164
+
165
+ ---
166
+ | |
167
+ -y-
168
+ """
169
+ return self.bbox(artist).min[1]
170
+
171
+ def max_width(self, artists: Sequence[Artist]) -> float:
172
+ """
173
+ Return the maximum width of list of artists
174
+ """
175
+ widths = [
176
+ bbox_in_figure_space(a, self.figure, self.renderer).width
177
+ for a in artists
178
+ ]
179
+ return max(widths) if len(widths) else 0
180
+
181
+ def max_height(self, artists: Sequence[Artist]) -> float:
182
+ """
183
+ Return the maximum height of list of artists
184
+ """
185
+ heights = [
186
+ bbox_in_figure_space(a, self.figure, self.renderer).height
187
+ for a in artists
188
+ ]
189
+ return max(heights) if len(heights) else 0
190
+
191
+
192
+ @dataclass
193
+ class LayoutItems:
194
+ """
195
+ Objects required to compute the layout
196
+ """
197
+
198
+ plot: ggplot
199
+
200
+ def __post_init__(self):
201
+ def get(name: str) -> Any:
202
+ """
203
+ Return themeable target or None
204
+ """
205
+ if self._is_blank(name):
206
+ return None
207
+ else:
208
+ t = getattr(self.plot.theme.targets, name)
209
+ if isinstance(t, Text) and t.get_text() == "":
210
+ return None
211
+ return t
212
+
213
+ self.calc = Calc(self.plot)
214
+
215
+ self.axis_title_x: Text | None = get("axis_title_x")
216
+ self.axis_title_y: Text | None = get("axis_title_y")
217
+
218
+ # # The legends references the structure that contains the
219
+ # # AnchoredOffsetboxes (groups of legends)
220
+ self.legends: legend_artists | None = get("legends")
221
+ self.plot_caption: Text | None = get("plot_caption")
222
+ self.plot_subtitle: Text | None = get("plot_subtitle")
223
+ self.plot_title: Text | None = get("plot_title")
224
+ self.plot_tag: Text | None = get("plot_tag")
225
+ self.strip_text_x: list[StripText] | None = get("strip_text_x")
226
+ self.strip_text_y: list[StripText] | None = get("strip_text_y")
227
+
228
+ def _is_blank(self, name: str) -> bool:
229
+ return self.plot.theme.T.is_blank(name)
230
+
231
+ def _filter_axes(self, location: AxesLocation = "all") -> list[Axes]:
232
+ """
233
+ Return subset of axes
234
+ """
235
+ axs = self.plot.axs
236
+
237
+ if location == "all":
238
+ return axs
239
+
240
+ # e.g. is_first_row, is_last_row, ..
241
+ pred_method = f"is_{location}"
242
+ return [
243
+ ax
244
+ for spec, ax in zip(get_subplotspecs(axs), axs)
245
+ if getattr(spec, pred_method)()
246
+ ]
247
+
248
+ def axis_text_x(self, ax: Axes) -> Iterator[Text]:
249
+ """
250
+ Return all x-axis labels for an axes that will be shown
251
+ """
252
+ major, minor = [], []
253
+
254
+ if not self._is_blank("axis_text_x"):
255
+ major = ax.xaxis.get_major_ticks()
256
+ minor = ax.xaxis.get_minor_ticks()
257
+
258
+ return (
259
+ tick.label1
260
+ for tick in chain(major, minor)
261
+ if _text_is_visible(tick.label1)
262
+ )
263
+
264
+ def axis_text_y(self, ax: Axes) -> Iterator[Text]:
265
+ """
266
+ Return all y-axis labels for an axes that will be shown
267
+ """
268
+ major, minor = [], []
269
+
270
+ if not self._is_blank("axis_text_y"):
271
+ major = ax.yaxis.get_major_ticks()
272
+ minor = ax.yaxis.get_minor_ticks()
273
+
274
+ return (
275
+ tick.label1
276
+ for tick in chain(major, minor)
277
+ if _text_is_visible(tick.label1)
278
+ )
279
+
280
+ def axis_ticks_x(self, ax: Axes) -> Iterator[Tick]:
281
+ """
282
+ Return all XTicks that will be shown
283
+ """
284
+ major, minor = [], []
285
+
286
+ if not self._is_blank("axis_ticks_major_x"):
287
+ major = ax.xaxis.get_major_ticks()
288
+
289
+ if not self._is_blank("axis_ticks_minor_x"):
290
+ minor = ax.xaxis.get_minor_ticks()
291
+
292
+ return chain(major, minor)
293
+
294
+ def axis_ticks_y(self, ax: Axes) -> Iterator[Tick]:
295
+ """
296
+ Return all YTicks that will be shown
297
+ """
298
+ major, minor = [], []
299
+
300
+ if not self._is_blank("axis_ticks_major_y"):
301
+ major = ax.yaxis.get_major_ticks()
302
+
303
+ if not self._is_blank("axis_ticks_minor_y"):
304
+ minor = ax.yaxis.get_minor_ticks()
305
+
306
+ return chain(major, minor)
307
+
308
+ def strip_text_x_extra_height(self, position: StripPosition) -> float:
309
+ """
310
+ Height taken up by the top strips that is outside the panels
311
+ """
312
+ if not self.strip_text_x:
313
+ return 0
314
+
315
+ artists = [
316
+ st.patch if st.patch.get_visible() else st
317
+ for st in self.strip_text_x
318
+ if st.patch.position == position
319
+ ]
320
+
321
+ heights = []
322
+
323
+ for a in artists:
324
+ info = (
325
+ a.text.draw_info
326
+ if isinstance(a, StripTextPatch)
327
+ else a.draw_info
328
+ )
329
+ h = self.calc.height(a)
330
+ heights.append(max(h + h * info.strip_align, 0))
331
+
332
+ return max(heights)
333
+
334
+ def strip_text_y_extra_width(self, position: StripPosition) -> float:
335
+ """
336
+ Width taken up by the top strips that is outside the panels
337
+ """
338
+ if not self.strip_text_y:
339
+ return 0
340
+
341
+ artists = [
342
+ st.patch if st.patch.get_visible() else st
343
+ for st in self.strip_text_y
344
+ if st.patch.position == position
345
+ ]
346
+
347
+ widths = []
348
+
349
+ for a in artists:
350
+ info = (
351
+ a.text.draw_info
352
+ if isinstance(a, StripTextPatch)
353
+ else a.draw_info
354
+ )
355
+ w = self.calc.width(a)
356
+ widths.append(max(w + w * info.strip_align, 0))
357
+
358
+ return max(widths)
359
+
360
+ def axis_ticks_x_max_height_at(self, location: AxesLocation) -> float:
361
+ """
362
+ Return maximum height[figure space] of x ticks
363
+ """
364
+ heights = [
365
+ self.calc.tight_height(tick.tick1line)
366
+ for ax in self._filter_axes(location)
367
+ for tick in self.axis_ticks_x(ax)
368
+ ]
369
+ return max(heights) if len(heights) else 0
370
+
371
+ def axis_text_x_max_height(self, ax: Axes) -> float:
372
+ """
373
+ Return maximum height[figure space] of x tick labels
374
+ """
375
+ heights = [
376
+ self.calc.tight_height(label) for label in self.axis_text_x(ax)
377
+ ]
378
+ return max(heights) if len(heights) else 0
379
+
380
+ def axis_text_x_max_height_at(self, location: AxesLocation) -> float:
381
+ """
382
+ Return maximum height[figure space] of x tick labels
383
+ """
384
+ heights = [
385
+ self.axis_text_x_max_height(ax)
386
+ for ax in self._filter_axes(location)
387
+ ]
388
+ return max(heights) if len(heights) else 0
389
+
390
+ def axis_ticks_y_max_width_at(self, location: AxesLocation) -> float:
391
+ """
392
+ Return maximum width[figure space] of y ticks
393
+ """
394
+ widths = [
395
+ self.calc.tight_width(tick.tick1line)
396
+ for ax in self._filter_axes(location)
397
+ for tick in self.axis_ticks_y(ax)
398
+ ]
399
+ return max(widths) if len(widths) else 0
400
+
401
+ def axis_text_y_max_width(self, ax: Axes) -> float:
402
+ """
403
+ Return maximum width[figure space] of y tick labels
404
+ """
405
+ widths = [
406
+ self.calc.tight_width(label) for label in self.axis_text_y(ax)
407
+ ]
408
+ return max(widths) if len(widths) else 0
409
+
410
+ def axis_text_y_max_width_at(self, location: AxesLocation) -> float:
411
+ """
412
+ Return maximum width[figure space] of y tick labels
413
+ """
414
+ widths = [
415
+ self.axis_text_y_max_width(ax)
416
+ for ax in self._filter_axes(location)
417
+ ]
418
+ return max(widths) if len(widths) else 0
419
+
420
+ def axis_text_y_top_protrusion(self, location: AxesLocation) -> float:
421
+ """
422
+ Return maximum height[figure space] above the axes of y tick labels
423
+ """
424
+ extras = []
425
+ for ax in self._filter_axes(location):
426
+ ax_top_y = self.calc.top_y(ax)
427
+ for label in self.axis_text_y(ax):
428
+ label_top_y = self.calc.top_y(label)
429
+ extras.append(max(0, label_top_y - ax_top_y))
430
+
431
+ return max(extras) if len(extras) else 0
432
+
433
+ def axis_text_y_bottom_protrusion(self, location: AxesLocation) -> float:
434
+ """
435
+ Return maximum height[figure space] below the axes of y tick labels
436
+ """
437
+ extras = []
438
+ for ax in self._filter_axes(location):
439
+ ax_bottom_y = self.calc.bottom_y(ax)
440
+ for label in self.axis_text_y(ax):
441
+ label_bottom_y = self.calc.bottom_y(label)
442
+ protrusion = abs(min(label_bottom_y - ax_bottom_y, 0))
443
+ extras.append(protrusion)
444
+
445
+ return max(extras) if len(extras) else 0
446
+
447
+ def axis_text_x_left_protrusion(self, location: AxesLocation) -> float:
448
+ """
449
+ Return maximum width[figure space] left of the axes of x tick labels
450
+ """
451
+ extras = []
452
+ for ax in self._filter_axes(location):
453
+ ax_left_x = self.calc.left_x(ax)
454
+ for label in self.axis_text_x(ax):
455
+ label_left_x = self.calc.left_x(label)
456
+ protrusion = abs(min(label_left_x - ax_left_x, 0))
457
+ extras.append(protrusion)
458
+
459
+ return max(extras) if len(extras) else 0
460
+
461
+ def axis_text_x_right_protrusion(self, location: AxesLocation) -> float:
462
+ """
463
+ Return maximum width[figure space] right of the axes of y tick labels
464
+ """
465
+ extras = []
466
+ for ax in self._filter_axes(location):
467
+ ax_right_x = self.calc.right_x(ax)
468
+ for label in self.axis_text_x(ax):
469
+ label_right_x = self.calc.right_x(label)
470
+ extras.append(max(0, label_right_x - ax_right_x))
471
+
472
+ return max(extras) if len(extras) else 0
473
+
474
+ def _adjust_positions(self, spaces: LayoutSpaces):
475
+ """
476
+ Set the x,y position of the artists around the panels
477
+ """
478
+ theme = self.plot.theme
479
+ plot_title_position = theme.getp("plot_title_position", "panel")
480
+ plot_caption_position = theme.getp("plot_caption_position", "panel")
481
+ justify = TextJustifier(spaces)
482
+
483
+ if self.plot_tag:
484
+ set_plot_tag_position(self.plot_tag, spaces)
485
+
486
+ if self.plot_title:
487
+ ha = theme.getp(("plot_title", "ha"))
488
+ self.plot_title.set_y(spaces.t.y2("plot_title"))
489
+ justify.horizontally_about(
490
+ self.plot_title, ha, plot_title_position
491
+ )
492
+
493
+ if self.plot_subtitle:
494
+ ha = theme.getp(("plot_subtitle", "ha"))
495
+ self.plot_subtitle.set_y(spaces.t.y2("plot_subtitle"))
496
+ justify.horizontally_about(
497
+ self.plot_subtitle, ha, plot_title_position
498
+ )
499
+
500
+ if self.plot_caption:
501
+ ha = theme.getp(("plot_caption", "ha"), "right")
502
+ self.plot_caption.set_y(spaces.b.y1("plot_caption"))
503
+ justify.horizontally_about(
504
+ self.plot_caption, ha, plot_caption_position
505
+ )
506
+
507
+ if self.axis_title_x:
508
+ ha = theme.getp(("axis_title_x", "ha"), "center")
509
+ self.axis_title_x.set_y(spaces.b.y1("axis_title_x"))
510
+ justify.horizontally_about(self.axis_title_x, ha, "panel")
511
+
512
+ if self.axis_title_y:
513
+ va = theme.getp(("axis_title_y", "va"), "center")
514
+ self.axis_title_y.set_x(spaces.l.x1("axis_title_y"))
515
+ justify.vertically_about(self.axis_title_y, va, "panel")
516
+
517
+ if self.legends:
518
+ set_legends_position(self.legends, spaces)
519
+
520
+ self._adjust_axis_text_x(justify)
521
+ self._adjust_axis_text_y(justify)
522
+ self._strip_text_x_background_equal_heights()
523
+ self._strip_text_y_background_equal_widths()
524
+
525
+ def _adjust_axis_text_x(self, justify: TextJustifier):
526
+ """
527
+ Adjust x-axis text, justifying vertically as necessary
528
+ """
529
+
530
+ def to_vertical_axis_dimensions(value: float, ax: Axes) -> float:
531
+ """
532
+ Convert value in figure dimensions to axis dimensions
533
+ """
534
+ _, H = self.plot.figure.bbox.size
535
+ h = ax.get_window_extent().height
536
+ return value * H / h
537
+
538
+ if self._is_blank("axis_text_x"):
539
+ return
540
+
541
+ va = self.plot.theme.getp(("axis_text_x", "va"), "top")
542
+
543
+ for ax in self.plot.axs:
544
+ texts = list(self.axis_text_x(ax))
545
+ axis_text_row_height = to_vertical_axis_dimensions(
546
+ self.axis_text_x_max_height(ax), ax
547
+ )
548
+ for text in texts:
549
+ height = to_vertical_axis_dimensions(
550
+ self.calc.tight_height(text), ax
551
+ )
552
+ justify.vertically(
553
+ text, va, -axis_text_row_height, 0, height=height
554
+ )
555
+
556
+ def _adjust_axis_text_y(self, justify: TextJustifier):
557
+ """
558
+ Adjust x-axis text, justifying horizontally as necessary
559
+ """
560
+
561
+ def to_horizontal_axis_dimensions(value: float, ax: Axes) -> float:
562
+ """
563
+ Convert value in figure dimensions to axis dimensions
564
+
565
+ Matplotlib expects x position of y-axis text is in transAxes,
566
+ but all our layout measurements are in transFigure.
567
+
568
+ ---------------------
569
+ | |
570
+ | ----------- |
571
+ | X | | |
572
+ | X | | |
573
+ | X | | |
574
+ | X | | |
575
+ | X | | |
576
+ | X | | |
577
+ | 0-----------1 |
578
+ | axes |
579
+ | |
580
+ 0---------------------1
581
+ figure
582
+
583
+ We do not set the transform to transFigure because, then we need
584
+ to calculate the position in transFigure; accounting for all the
585
+ space wherever the panel may be.
586
+ """
587
+ W, _ = self.plot.figure.bbox.size
588
+ w = ax.get_window_extent().width
589
+ return value * W / w
590
+
591
+ if self._is_blank("axis_text_y"):
592
+ return
593
+
594
+ ha = self.plot.theme.getp(("axis_text_y", "ha"), "right")
595
+
596
+ for ax in self.plot.axs:
597
+ texts = list(self.axis_text_y(ax))
598
+ axis_text_col_width = to_horizontal_axis_dimensions(
599
+ self.axis_text_y_max_width(ax), ax
600
+ )
601
+ for text in texts:
602
+ width = to_horizontal_axis_dimensions(
603
+ self.calc.tight_width(text), ax
604
+ )
605
+ justify.horizontally(
606
+ text, ha, -axis_text_col_width, 0, width=width
607
+ )
608
+
609
+ def _strip_text_x_background_equal_heights(self):
610
+ """
611
+ Make the strip_text_x_backgrounds have equal heights
612
+
613
+ The smaller heights are expanded to match the largest height
614
+ """
615
+ if not self.strip_text_x:
616
+ return
617
+
618
+ heights = [self.calc.bbox(t.patch).height for t in self.strip_text_x]
619
+ max_height = max(heights)
620
+ relative_heights = [max_height / h for h in heights]
621
+ for text, scale in zip(self.strip_text_x, relative_heights):
622
+ text.patch.expand = scale
623
+
624
+ def _strip_text_y_background_equal_widths(self):
625
+ """
626
+ Make the strip_text_y_backgrounds have equal widths
627
+
628
+ The smaller widths are expanded to match the largest width
629
+ """
630
+ if not self.strip_text_y:
631
+ return
632
+
633
+ widths = [self.calc.bbox(t.patch).width for t in self.strip_text_y]
634
+ max_width = max(widths)
635
+ relative_widths = [max_width / w for w in widths]
636
+ for text, scale in zip(self.strip_text_y, relative_widths):
637
+ text.patch.expand = scale
638
+
639
+
640
+ def _text_is_visible(text: Text) -> bool:
641
+ """
642
+ Return True if text is visible and is not empty
643
+ """
644
+ return text.get_visible() and text._text # type: ignore
645
+
646
+
647
+ @dataclass
648
+ class TextJustifier:
649
+ """
650
+ Justify Text
651
+
652
+ The justification methods reinterpret alignment values to be justification
653
+ about a span.
654
+ """
655
+
656
+ spaces: LayoutSpaces
657
+
658
+ def horizontally(
659
+ self,
660
+ text: Text,
661
+ ha: HorizontalJustification | float,
662
+ left: float,
663
+ right: float,
664
+ width: float | None = None,
665
+ ):
666
+ """
667
+ Horizontally Justify text between left and right
668
+ """
669
+ rel = ha_as_float(ha)
670
+ if width is None:
671
+ width = self.spaces.items.calc.width(text)
672
+ x = rel_position(rel, width, left, right)
673
+ text.set_x(x)
674
+ text.set_horizontalalignment("left")
675
+
676
+ def vertically(
677
+ self,
678
+ text: Text,
679
+ va: VerticalJustification | float,
680
+ bottom: float,
681
+ top: float,
682
+ height: float | None = None,
683
+ ):
684
+ """
685
+ Vertically Justify text between bottom and top
686
+ """
687
+ rel = va_as_float(va)
688
+
689
+ if height is None:
690
+ height = self.spaces.items.calc.height(text)
691
+ y = rel_position(rel, height, bottom, top)
692
+ text.set_y(y)
693
+ text.set_verticalalignment("bottom")
694
+
695
+ def horizontally_across_panel(
696
+ self, text: Text, ha: HorizontalJustification | float
697
+ ):
698
+ """
699
+ Horizontally Justify text accross the panel(s) width
700
+ """
701
+ self.horizontally(text, ha, self.spaces.l.left, self.spaces.r.right)
702
+
703
+ def horizontally_across_plot(
704
+ self, text: Text, ha: HorizontalJustification | float
705
+ ):
706
+ """
707
+ Horizontally Justify text across the plot's width
708
+ """
709
+ self.horizontally(
710
+ text, ha, self.spaces.l.plot_left, self.spaces.r.plot_right
711
+ )
712
+
713
+ def vertically_along_panel(
714
+ self, text: Text, va: VerticalJustification | float
715
+ ):
716
+ """
717
+ Horizontally Justify text along the panel(s) height
718
+ """
719
+ self.vertically(text, va, self.spaces.b.bottom, self.spaces.t.top)
720
+
721
+ def vertically_along_plot(
722
+ self, text: Text, va: VerticalJustification | float
723
+ ):
724
+ """
725
+ Vertically Justify text along the plot's height
726
+ """
727
+ self.vertically(
728
+ text, va, self.spaces.b.plot_bottom, self.spaces.t.plot_top
729
+ )
730
+
731
+ def horizontally_about(
732
+ self, text: Text, ratio: float, how: Literal["panel", "plot"]
733
+ ):
734
+ """
735
+ Horizontally Justify text across the panel or plot
736
+ """
737
+ if how == "panel":
738
+ self.horizontally_across_panel(text, ratio)
739
+ else:
740
+ self.horizontally_across_plot(text, ratio)
741
+
742
+ def vertically_about(
743
+ self, text: Text, ratio: float, how: Literal["panel", "plot"]
744
+ ):
745
+ """
746
+ Vertically Justify text along the panel or plot
747
+ """
748
+ if how == "panel":
749
+ self.vertically_along_panel(text, ratio)
750
+ else:
751
+ self.vertically_along_plot(text, ratio)
752
+
753
+
754
+ def set_legends_position(legends: legend_artists, spaces: LayoutSpaces):
755
+ """
756
+ Place legend on the figure and justify is a required
757
+ """
758
+ panels_gs = spaces.plot.facet._panels_gridspec
759
+ params = panels_gs.get_subplot_params()
760
+ transFigure = spaces.plot.figure.transFigure
761
+
762
+ def set_position(
763
+ aob: FlexibleAnchoredOffsetbox,
764
+ anchor_point: tuple[float, float],
765
+ xy_loc: tuple[float, float],
766
+ transform: Transform = transFigure,
767
+ ):
768
+ """
769
+ Place box (by the anchor point) at given xy location
770
+
771
+ Parameters
772
+ ----------
773
+ aob :
774
+ Offsetbox to place
775
+ anchor_point :
776
+ Point on the Offsefbox.
777
+ xy_loc :
778
+ Point where to place the offsetbox.
779
+ transform :
780
+ Transformation
781
+ """
782
+ aob.xy_loc = xy_loc
783
+ aob.set_bbox_to_anchor(anchor_point, transform) # type: ignore
784
+
785
+ if legends.right:
786
+ y = rel_position(
787
+ legends.right.justification,
788
+ spaces.r.legend_height,
789
+ params.bottom,
790
+ params.top,
791
+ )
792
+ x = spaces.r.x2("legend")
793
+ set_position(legends.right.box, (x, y), (1, 0))
794
+
795
+ if legends.left:
796
+ y = rel_position(
797
+ legends.left.justification,
798
+ spaces.l.legend_height,
799
+ params.bottom,
800
+ params.top,
801
+ )
802
+ x = spaces.l.x1("legend")
803
+ set_position(legends.left.box, (x, y), (0, 0))
804
+
805
+ if legends.top:
806
+ x = rel_position(
807
+ legends.top.justification,
808
+ spaces.t.legend_width,
809
+ params.left,
810
+ params.right,
811
+ )
812
+ y = spaces.t.y2("legend")
813
+ set_position(legends.top.box, (x, y), (0, 1))
814
+
815
+ if legends.bottom:
816
+ x = rel_position(
817
+ legends.bottom.justification,
818
+ spaces.b.legend_width,
819
+ params.left,
820
+ params.right,
821
+ )
822
+ y = spaces.b.y1("legend")
823
+ set_position(legends.bottom.box, (x, y), (0, 0))
824
+
825
+ # Inside legends are placed using the panels coordinate system
826
+ if legends.inside:
827
+ transPanels = panels_gs.to_transform()
828
+ for l in legends.inside:
829
+ set_position(l.box, l.position, l.justification, transPanels)
830
+
831
+
832
+ def set_plot_tag_position(tag: Text, spaces: LayoutSpaces):
833
+ """
834
+ Set the postion of the plot_tag
835
+ """
836
+ theme = spaces.plot.theme
837
+ panels_gs = spaces.plot.facet._panels_gridspec
838
+ location: TagLocation = theme.getp("plot_tag_location")
839
+ position: TagPosition = theme.getp("plot_tag_position")
840
+ margin = theme.get_margin("plot_tag")
841
+
842
+ if location == "margin":
843
+ return set_plot_tag_position_in_margin(tag, spaces)
844
+
845
+ lookup: dict[str, tuple[float, float]] = {
846
+ "topleft": (0, 1),
847
+ "top": (0.5, 1),
848
+ "topright": (1, 1),
849
+ "left": (0, 0.5),
850
+ "right": (1, 0.5),
851
+ "bottomleft": (0, 0),
852
+ "bottom": (0.5, 0),
853
+ "bottomright": (1, 0),
854
+ }
855
+
856
+ if isinstance(position, str):
857
+ # Coordinates of the space in which to place the tag
858
+ if location == "plot":
859
+ (x1, y1), (x2, y2) = spaces.plot_area_coordinates
860
+ else:
861
+ (x1, y1), (x2, y2) = spaces.panel_area_coordinates
862
+
863
+ # Calculate the position when the tag has no margins
864
+ rel_x, rel_y = lookup[position]
865
+ width, height = spaces.items.calc.size(tag)
866
+ x = rel_position(rel_x, width, x1, x2)
867
+ y = rel_position(rel_y, height, y1, y2)
868
+
869
+ # Adjust the position to account for the margins
870
+ # When the units for the margin are in the figure coordinates,
871
+ # the adjustment is proportional to the size of the space.
872
+ # For points, inches and lines, the adjustment is absolute.
873
+ mx, my = _plot_tag_margin_adjustment(margin, position)
874
+ if margin.unit == "fig":
875
+ panel_width, panel_height = (x2 - x1), (y2 - y1)
876
+ else:
877
+ panel_width, panel_height = 1, 1
878
+
879
+ x += panel_width * mx
880
+ y += panel_height * my
881
+
882
+ position = (x, y)
883
+ tag.set_horizontalalignment("left")
884
+ tag.set_verticalalignment("bottom")
885
+ else:
886
+ if location == "panel":
887
+ transPanels = panels_gs.to_transform()
888
+ tag.set_transform(transPanels)
889
+
890
+ tag.set_position(position)
891
+
892
+
893
+ def set_plot_tag_position_in_margin(tag: Text, spaces: LayoutSpaces):
894
+ """
895
+ Place the tag in an inner margin around the plot
896
+
897
+ The panel_margin remains outside the tag. For compositions, the
898
+ tag is placed and within the tag_alignment space.
899
+ """
900
+ position: TagPosition = spaces.plot.theme.getp("plot_tag_position")
901
+ if not isinstance(position, str):
902
+ raise PlotnineError(
903
+ f"Cannot have plot_tag_location='margin' if "
904
+ f"plot_tag_position={position!r}."
905
+ )
906
+
907
+ tag.set_position(spaces.to_figure_space((0.5, 0.5)))
908
+ ha = spaces.plot.theme.get_ha("plot_tag")
909
+ va = spaces.plot.theme.get_va("plot_tag")
910
+ if "left" in position: # left, topleft, bottomleft
911
+ space = spaces.l.tag_alignment
912
+ x = spaces.l.x1("plot_tag") - (1 - ha) * space
913
+ tag.set_x(x)
914
+ tag.set_horizontalalignment("left")
915
+ if "right" in position: # right, topright, bottomright
916
+ space = spaces.r.tag_alignment
917
+ x = spaces.r.x1("plot_tag") + ha * space
918
+ tag.set_x(x)
919
+ tag.set_horizontalalignment("left")
920
+ if "bottom" in position: # bottom, bottomleft, bottomright
921
+ space = spaces.b.tag_alignment
922
+ y = spaces.b.y1("plot_tag") + (1 - va) * space
923
+ tag.set_y(y)
924
+ tag.set_verticalalignment("bottom")
925
+ if "top" in position: # top, topleft, topright
926
+ space = spaces.t.tag_alignment
927
+ y = spaces.t.y1("plot_tag") + va * space
928
+ tag.set_y(y)
929
+ tag.set_verticalalignment("bottom")
930
+
931
+ justify = TextJustifier(spaces)
932
+ if position in ("left", "right"):
933
+ justify.vertically_along_plot(tag, va)
934
+ elif position in ("top", "bottom"):
935
+ justify.horizontally_across_plot(tag, ha)
936
+
937
+
938
+ def _plot_tag_margin_adjustment(
939
+ margin: Margin, position: str
940
+ ) -> tuple[float, float]:
941
+ """
942
+ How to adjust the plot_tag to account for the margin
943
+ """
944
+ m = margin.fig
945
+ dx, dy = 0, 0
946
+
947
+ if "top" in position:
948
+ dy = -m.t
949
+ elif "bottom" in position:
950
+ dy = m.b
951
+
952
+ if "left" in position:
953
+ dx = m.l
954
+ elif "right" in position:
955
+ dx = -m.r
956
+
957
+ return (dx, dy)