plotnine 0.14.5__py3-none-any.whl → 0.15.0.dev2__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 (64) 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 +916 -0
  6. plotnine/_mpl/layout_manager/_layout_tree.py +625 -0
  7. plotnine/_mpl/layout_manager/_spaces.py +1007 -0
  8. plotnine/_mpl/patches.py +1 -1
  9. plotnine/_mpl/text.py +59 -24
  10. plotnine/_mpl/utils.py +78 -10
  11. plotnine/_utils/__init__.py +5 -5
  12. plotnine/_utils/dev.py +45 -27
  13. plotnine/animation.py +1 -1
  14. plotnine/coords/coord_trans.py +1 -1
  15. plotnine/data/__init__.py +12 -8
  16. plotnine/doctools.py +1 -1
  17. plotnine/facets/facet.py +30 -39
  18. plotnine/facets/facet_grid.py +14 -6
  19. plotnine/facets/facet_wrap.py +3 -5
  20. plotnine/facets/strips.py +7 -9
  21. plotnine/geoms/geom_crossbar.py +2 -3
  22. plotnine/geoms/geom_path.py +1 -1
  23. plotnine/ggplot.py +94 -65
  24. plotnine/guides/guide.py +12 -10
  25. plotnine/guides/guide_colorbar.py +3 -3
  26. plotnine/guides/guide_legend.py +12 -13
  27. plotnine/guides/guides.py +3 -3
  28. plotnine/iapi.py +5 -2
  29. plotnine/labels.py +5 -0
  30. plotnine/mapping/aes.py +4 -3
  31. plotnine/options.py +14 -7
  32. plotnine/plot_composition/__init__.py +10 -0
  33. plotnine/plot_composition/_compose.py +436 -0
  34. plotnine/plot_composition/_plotspec.py +50 -0
  35. plotnine/plot_composition/_spacer.py +32 -0
  36. plotnine/positions/position_dodge.py +1 -1
  37. plotnine/positions/position_dodge2.py +1 -1
  38. plotnine/positions/position_stack.py +1 -2
  39. plotnine/qplot.py +1 -2
  40. plotnine/scales/__init__.py +0 -6
  41. plotnine/scales/scale.py +1 -1
  42. plotnine/stats/binning.py +1 -1
  43. plotnine/stats/smoothers.py +3 -5
  44. plotnine/stats/stat_density.py +1 -1
  45. plotnine/stats/stat_qq_line.py +1 -1
  46. plotnine/stats/stat_sina.py +1 -1
  47. plotnine/themes/elements/__init__.py +2 -0
  48. plotnine/themes/elements/element_text.py +35 -24
  49. plotnine/themes/elements/margin.py +73 -60
  50. plotnine/themes/targets.py +3 -1
  51. plotnine/themes/theme.py +13 -7
  52. plotnine/themes/theme_gray.py +28 -31
  53. plotnine/themes/theme_matplotlib.py +25 -28
  54. plotnine/themes/theme_seaborn.py +31 -34
  55. plotnine/themes/theme_void.py +17 -26
  56. plotnine/themes/themeable.py +290 -157
  57. {plotnine-0.14.5.dist-info → plotnine-0.15.0.dev2.dist-info}/METADATA +4 -3
  58. {plotnine-0.14.5.dist-info → plotnine-0.15.0.dev2.dist-info}/RECORD +61 -54
  59. {plotnine-0.14.5.dist-info → plotnine-0.15.0.dev2.dist-info}/WHEEL +1 -1
  60. plotnine/_mpl/_plot_side_space.py +0 -888
  61. plotnine/_mpl/_plotnine_tight_layout.py +0 -293
  62. plotnine/_mpl/layout_engine.py +0 -110
  63. {plotnine-0.14.5.dist-info → plotnine-0.15.0.dev2.dist-info/licenses}/LICENSE +0 -0
  64. {plotnine-0.14.5.dist-info → plotnine-0.15.0.dev2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,916 @@
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_at(self, location: AxesLocation) -> float:
365
+ """
366
+ Return maximum height[figure space] 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, ax: Axes) -> float:
376
+ """
377
+ Return maximum height[figure space] of x tick labels
378
+ """
379
+ heights = [
380
+ self.calc.tight_height(label) + pad
381
+ for label, pad in zip(
382
+ self.axis_text_x(ax), self.axis_text_x_margin(ax)
383
+ )
384
+ ]
385
+ return max(heights) if len(heights) else 0
386
+
387
+ def axis_text_x_max_height_at(self, location: AxesLocation) -> float:
388
+ """
389
+ Return maximum height[figure space] of x tick labels
390
+ """
391
+ heights = [
392
+ self.axis_text_x_max_height(ax)
393
+ for ax in self._filter_axes(location)
394
+ ]
395
+ return max(heights) if len(heights) else 0
396
+
397
+ def axis_ticks_y_max_width_at(self, location: AxesLocation) -> float:
398
+ """
399
+ Return maximum width[figure space] of y ticks
400
+ """
401
+ widths = [
402
+ self.calc.tight_width(tick.tick1line)
403
+ for ax in self._filter_axes(location)
404
+ for tick in self.axis_ticks_y(ax)
405
+ ]
406
+ return max(widths) if len(widths) else 0
407
+
408
+ def axis_text_y_max_width(self, ax: Axes) -> float:
409
+ """
410
+ Return maximum width[figure space] of y tick labels
411
+ """
412
+ widths = [
413
+ self.calc.tight_width(label) + pad
414
+ for label, pad in zip(
415
+ self.axis_text_y(ax), self.axis_text_y_margin(ax)
416
+ )
417
+ ]
418
+ return max(widths) if len(widths) else 0
419
+
420
+ def axis_text_y_max_width_at(self, location: AxesLocation) -> float:
421
+ """
422
+ Return maximum width[figure space] of y tick labels
423
+ """
424
+ widths = [
425
+ self.axis_text_y_max_width(ax)
426
+ for ax in self._filter_axes(location)
427
+ ]
428
+ return max(widths) if len(widths) else 0
429
+
430
+ def axis_text_y_top_protrusion(self, location: AxesLocation) -> float:
431
+ """
432
+ Return maximum height[figure space] above the axes of y tick labels
433
+ """
434
+ extras = []
435
+ for ax in self._filter_axes(location):
436
+ ax_top_y = self.calc.top_y(ax)
437
+ for label in self.axis_text_y(ax):
438
+ label_top_y = self.calc.top_y(label)
439
+ extras.append(max(0, label_top_y - ax_top_y))
440
+
441
+ return max(extras) if len(extras) else 0
442
+
443
+ def axis_text_y_bottom_protrusion(self, location: AxesLocation) -> float:
444
+ """
445
+ Return maximum height[figure space] below the axes of y tick labels
446
+ """
447
+ extras = []
448
+ for ax in self._filter_axes(location):
449
+ ax_bottom_y = self.calc.bottom_y(ax)
450
+ for label in self.axis_text_y(ax):
451
+ label_bottom_y = self.calc.bottom_y(label)
452
+ protrusion = abs(min(label_bottom_y - ax_bottom_y, 0))
453
+ extras.append(protrusion)
454
+
455
+ return max(extras) if len(extras) else 0
456
+
457
+ def axis_text_x_left_protrusion(self, location: AxesLocation) -> float:
458
+ """
459
+ Return maximum width[figure space] left of the axes of x tick labels
460
+ """
461
+ extras = []
462
+ for ax in self._filter_axes(location):
463
+ ax_left_x = self.calc.left_x(ax)
464
+ for label in self.axis_text_x(ax):
465
+ label_left_x = self.calc.left_x(label)
466
+ protrusion = abs(min(label_left_x - ax_left_x, 0))
467
+ extras.append(protrusion)
468
+
469
+ return max(extras) if len(extras) else 0
470
+
471
+ def axis_text_x_right_protrusion(self, location: AxesLocation) -> float:
472
+ """
473
+ Return maximum width[figure space] right of the axes of y tick labels
474
+ """
475
+ extras = []
476
+ for ax in self._filter_axes(location):
477
+ ax_right_x = self.calc.right_x(ax)
478
+ for label in self.axis_text_x(ax):
479
+ label_right_x = self.calc.right_x(label)
480
+ extras.append(max(0, label_right_x - ax_right_x))
481
+
482
+ return max(extras) if len(extras) else 0
483
+
484
+ def _adjust_positions(self, spaces: LayoutSpaces):
485
+ """
486
+ Set the x,y position of the artists around the panels
487
+ """
488
+ theme = self.plot.theme
489
+ plot_title_position = theme.getp("plot_title_position", "panel")
490
+ plot_caption_position = theme.getp("plot_caption_position", "panel")
491
+ justify = TextJustifier(spaces)
492
+
493
+ if self.plot_tag:
494
+ set_plot_tag_position(self.plot_tag, spaces)
495
+
496
+ if self.plot_title:
497
+ ha = theme.getp(("plot_title", "ha"))
498
+ self.plot_title.set_y(spaces.t.y2("plot_title"))
499
+ justify.horizontally_about(
500
+ self.plot_title, ha, plot_title_position
501
+ )
502
+
503
+ if self.plot_subtitle:
504
+ ha = theme.getp(("plot_subtitle", "ha"))
505
+ self.plot_subtitle.set_y(spaces.t.y2("plot_subtitle"))
506
+ justify.horizontally_about(
507
+ self.plot_subtitle, ha, plot_title_position
508
+ )
509
+
510
+ if self.plot_caption:
511
+ ha = theme.getp(("plot_caption", "ha"), "right")
512
+ self.plot_caption.set_y(spaces.b.y1("plot_caption"))
513
+ justify.horizontally_about(
514
+ self.plot_caption, ha, plot_caption_position
515
+ )
516
+
517
+ if self.axis_title_x:
518
+ ha = theme.getp(("axis_title_x", "ha"), "center")
519
+ self.axis_title_x.set_y(spaces.b.y1("axis_title_x"))
520
+ justify.horizontally_about(self.axis_title_x, ha, "panel")
521
+
522
+ if self.axis_title_y:
523
+ va = theme.getp(("axis_title_y", "va"), "center")
524
+ self.axis_title_y.set_x(spaces.l.x1("axis_title_y"))
525
+ justify.vertically_about(self.axis_title_y, va, "panel")
526
+
527
+ if self.legends:
528
+ set_legends_position(self.legends, spaces)
529
+
530
+ self._adjust_axis_text_x(justify)
531
+ self._adjust_axis_text_y(justify)
532
+
533
+ def _adjust_axis_text_x(self, justify: TextJustifier):
534
+ """
535
+ Adjust x-axis text, justifying vertically as necessary
536
+ """
537
+
538
+ def to_vertical_axis_dimensions(value: float, ax: Axes) -> float:
539
+ """
540
+ Convert value in figure dimensions to axis dimensions
541
+ """
542
+ _, H = self.plot.figure.bbox.size
543
+ h = ax.get_window_extent().height
544
+ return value * H / h
545
+
546
+ if self._is_blank("axis_text_x"):
547
+ return
548
+
549
+ va = self.plot.theme.getp(("axis_text_x", "va"), "top")
550
+
551
+ for ax in self.plot.axs:
552
+ texts = list(self.axis_text_x(ax))
553
+ axis_text_row_height = to_vertical_axis_dimensions(
554
+ self.axis_text_x_max_height(ax), ax
555
+ )
556
+ for text in texts:
557
+ height = to_vertical_axis_dimensions(
558
+ self.calc.tight_height(text), ax
559
+ )
560
+ justify.vertically(
561
+ text, va, -axis_text_row_height, 0, height=height
562
+ )
563
+
564
+ def _adjust_axis_text_y(self, justify: TextJustifier):
565
+ """
566
+ Adjust x-axis text, justifying horizontally as necessary
567
+ """
568
+
569
+ def to_horizontal_axis_dimensions(value: float, ax: Axes) -> float:
570
+ """
571
+ Convert value in figure dimensions to axis dimensions
572
+
573
+ Matplotlib expects x position of y-axis text is in transAxes,
574
+ but all our layout measurements are in transFigure.
575
+
576
+ ---------------------
577
+ | |
578
+ | ----------- |
579
+ | X | | |
580
+ | X | | |
581
+ | X | | |
582
+ | X | | |
583
+ | X | | |
584
+ | X | | |
585
+ | 0-----------1 |
586
+ | axes |
587
+ | |
588
+ 0---------------------1
589
+ figure
590
+
591
+ We do not set the transform to transFigure because, then we need
592
+ to calculate the position in transFigure; accounting for all the
593
+ space wherever the panel may be.
594
+ """
595
+ W, _ = self.plot.figure.bbox.size
596
+ w = ax.get_window_extent().width
597
+ return value * W / w
598
+
599
+ if self._is_blank("axis_text_y"):
600
+ return
601
+
602
+ ha = self.plot.theme.getp(("axis_text_y", "ha"), "right")
603
+
604
+ for ax in self.plot.axs:
605
+ texts = list(self.axis_text_y(ax))
606
+ axis_text_col_width = to_horizontal_axis_dimensions(
607
+ self.axis_text_y_max_width(ax), ax
608
+ )
609
+ for text in texts:
610
+ width = to_horizontal_axis_dimensions(
611
+ self.calc.tight_width(text), ax
612
+ )
613
+ justify.horizontally(
614
+ text, ha, -axis_text_col_width, 0, width=width
615
+ )
616
+
617
+
618
+ def _text_is_visible(text: Text) -> bool:
619
+ """
620
+ Return True if text is visible and is not empty
621
+ """
622
+ return text.get_visible() and text._text # type: ignore
623
+
624
+
625
+ @dataclass
626
+ class TextJustifier:
627
+ """
628
+ Justify Text
629
+
630
+ The justification methods reinterpret alignment values to be justification
631
+ about a span.
632
+ """
633
+
634
+ spaces: LayoutSpaces
635
+
636
+ def horizontally(
637
+ self,
638
+ text: Text,
639
+ ha: str | float,
640
+ left: float,
641
+ right: float,
642
+ width: float | None = None,
643
+ ):
644
+ """
645
+ Horizontally Justify text between left and right
646
+ """
647
+ lookup = {"left": 0.0, "center": 0.5, "right": 1.0}
648
+ rel = lookup.get(ha, ha) # pyright: ignore[reportCallIssue, reportArgumentType]
649
+ if width is None:
650
+ width = self.spaces.items.calc.width(text)
651
+ x = rel_position(rel, width, left, right)
652
+ text.set_x(x)
653
+ text.set_horizontalalignment("left")
654
+
655
+ def vertically(
656
+ self,
657
+ text: Text,
658
+ va: str | float,
659
+ bottom: float,
660
+ top: float,
661
+ height: float | None = None,
662
+ ):
663
+ """
664
+ Vertically Justify text between bottom and top
665
+ """
666
+ lookup = {
667
+ "top": 1.0,
668
+ "center": 0.5,
669
+ "baseline": 0.5,
670
+ "center_baseline": 0.5,
671
+ "bottom": 0.0,
672
+ }
673
+ rel = lookup.get(va, va) # pyright: ignore[reportCallIssue, reportArgumentType]
674
+
675
+ if height is None:
676
+ height = self.spaces.items.calc.height(text)
677
+ y = rel_position(rel, height, bottom, top)
678
+ text.set_y(y)
679
+ text.set_verticalalignment("bottom")
680
+
681
+ def horizontally_across_panel(self, text: Text, ha: str | float):
682
+ """
683
+ Horizontally Justify text accross the panel(s) width
684
+ """
685
+ self.horizontally(text, ha, self.spaces.l.left, self.spaces.r.right)
686
+
687
+ def horizontally_across_plot(self, text: Text, ha: str | float):
688
+ """
689
+ Horizontally Justify text across the plot's width
690
+ """
691
+ self.horizontally(
692
+ text, ha, self.spaces.l.plot_left, self.spaces.r.plot_right
693
+ )
694
+
695
+ def vertically_along_panel(self, text: Text, va: str | float):
696
+ """
697
+ Horizontally Justify text along the panel(s) height
698
+ """
699
+ self.vertically(text, va, self.spaces.b.bottom, self.spaces.t.top)
700
+
701
+ def vertically_along_plot(self, text: Text, va: str | float):
702
+ """
703
+ Vertically Justify text along the plot's height
704
+ """
705
+ self.vertically(
706
+ text, va, self.spaces.b.plot_bottom, self.spaces.t.plot_top
707
+ )
708
+
709
+ def horizontally_about(
710
+ self, text: Text, ratio: float, how: Literal["panel", "plot"]
711
+ ):
712
+ """
713
+ Horizontally Justify text across the panel or plot
714
+ """
715
+ if how == "panel":
716
+ self.horizontally_across_panel(text, ratio)
717
+ else:
718
+ self.horizontally_across_plot(text, ratio)
719
+
720
+ def vertically_about(
721
+ self, text: Text, ratio: float, how: Literal["panel", "plot"]
722
+ ):
723
+ """
724
+ Vertically Justify text along the panel or plot
725
+ """
726
+ if how == "panel":
727
+ self.vertically_along_panel(text, ratio)
728
+ else:
729
+ self.vertically_along_plot(text, ratio)
730
+
731
+
732
+ def set_legends_position(legends: legend_artists, spaces: LayoutSpaces):
733
+ """
734
+ Place legend on the figure and justify is a required
735
+ """
736
+ panels_gs = spaces.plot.facet._panels_gridspec
737
+ params = panels_gs.get_subplot_params()
738
+ transFigure = spaces.plot.figure.transFigure
739
+
740
+ def set_position(
741
+ aob: FlexibleAnchoredOffsetbox,
742
+ anchor_point: tuple[float, float],
743
+ xy_loc: tuple[float, float],
744
+ transform: Transform = transFigure,
745
+ ):
746
+ """
747
+ Place box (by the anchor point) at given xy location
748
+
749
+ Parameters
750
+ ----------
751
+ aob :
752
+ Offsetbox to place
753
+ anchor_point :
754
+ Point on the Offsefbox.
755
+ xy_loc :
756
+ Point where to place the offsetbox.
757
+ transform :
758
+ Transformation
759
+ """
760
+ aob.xy_loc = xy_loc
761
+ aob.set_bbox_to_anchor(anchor_point, transform) # type: ignore
762
+
763
+ if legends.right:
764
+ y = rel_position(
765
+ legends.right.justification,
766
+ spaces.r._legend_height,
767
+ params.bottom,
768
+ params.top,
769
+ )
770
+ x = spaces.r.x2("legend")
771
+ set_position(legends.right.box, (x, y), (1, 0))
772
+
773
+ if legends.left:
774
+ y = rel_position(
775
+ legends.left.justification,
776
+ spaces.l._legend_height,
777
+ params.bottom,
778
+ params.top,
779
+ )
780
+ x = spaces.l.x1("legend")
781
+ set_position(legends.left.box, (x, y), (0, 0))
782
+
783
+ if legends.top:
784
+ x = rel_position(
785
+ legends.top.justification,
786
+ spaces.t._legend_width,
787
+ params.left,
788
+ params.right,
789
+ )
790
+ y = spaces.t.y2("legend")
791
+ set_position(legends.top.box, (x, y), (0, 1))
792
+
793
+ if legends.bottom:
794
+ x = rel_position(
795
+ legends.bottom.justification,
796
+ spaces.b._legend_width,
797
+ params.left,
798
+ params.right,
799
+ )
800
+ y = spaces.b.y1("legend")
801
+ set_position(legends.bottom.box, (x, y), (0, 0))
802
+
803
+ # Inside legends are placed using the panels coordinate system
804
+ if legends.inside:
805
+ transPanels = panels_gs.to_transform()
806
+ for l in legends.inside:
807
+ set_position(l.box, l.position, l.justification, transPanels)
808
+
809
+
810
+ def set_plot_tag_position(tag: Text, spaces: LayoutSpaces):
811
+ """
812
+ Set the postion of the plot_tag
813
+ """
814
+ theme = spaces.plot.theme
815
+ panels_gs = spaces.plot.facet._panels_gridspec
816
+ location: TagLocation = theme.getp("plot_tag_location")
817
+ position: TagPosition = theme.getp("plot_tag_position")
818
+ margin = theme.get_margin("plot_tag")
819
+
820
+ if location == "margin":
821
+ return set_plot_tag_position_in_margin(tag, spaces)
822
+
823
+ lookup: dict[str, tuple[float, float]] = {
824
+ "topleft": (0, 1),
825
+ "top": (0.5, 1),
826
+ "topright": (1, 1),
827
+ "left": (0, 0.5),
828
+ "right": (1, 0.5),
829
+ "bottomleft": (0, 0),
830
+ "bottom": (0.5, 0),
831
+ "bottomright": (1, 0),
832
+ }
833
+
834
+ if isinstance(position, str):
835
+ # Coordinates of the space in which to place the tag
836
+ if location == "plot":
837
+ (x1, y1), (x2, y2) = spaces.plot_area_coordinates
838
+ else:
839
+ (x1, y1), (x2, y2) = spaces.panel_area_coordinates
840
+
841
+ # Calculate the position when the tag has no margins
842
+ rel_x, rel_y = lookup[position]
843
+ width, height = spaces.items.calc.size(tag)
844
+ x = rel_position(rel_x, width, x1, x2)
845
+ y = rel_position(rel_y, height, y1, y2)
846
+
847
+ # Adjust the position to account for the margins
848
+ # When the units for the margin are in the figure coordinates,
849
+ # the adjustment is proportional to the size of the space.
850
+ # For points, inches and lines, the adjustment is absolute.
851
+ mx, my = _plot_tag_margin_adjustment(margin, position)
852
+ if margin.unit == "fig":
853
+ panel_width, panel_height = (x2 - x1), (y2 - y1)
854
+ else:
855
+ panel_width, panel_height = 1, 1
856
+
857
+ x += panel_width * mx
858
+ y += panel_height * my
859
+
860
+ position = (x, y)
861
+ tag.set_horizontalalignment("left")
862
+ tag.set_verticalalignment("bottom")
863
+ else:
864
+ if location == "panel":
865
+ transPanels = panels_gs.to_transform()
866
+ tag.set_transform(transPanels)
867
+
868
+ tag.set_position(position)
869
+
870
+
871
+ def set_plot_tag_position_in_margin(tag: Text, spaces: LayoutSpaces):
872
+ """
873
+ Place the tag in the margin around the plot
874
+ """
875
+ position: TagPosition = spaces.plot.theme.getp("plot_tag_position")
876
+ if not isinstance(position, str):
877
+ raise PlotnineError(
878
+ f"Cannot have plot_tag_location='margin' if "
879
+ f"plot_tag_position={position!r}."
880
+ )
881
+
882
+ tag.set_position(spaces.to_figure_space((0.5, 0.5)))
883
+ if "top" in position:
884
+ tag.set_y(spaces.t.y2("plot_tag"))
885
+ tag.set_verticalalignment("top")
886
+ if "bottom" in position:
887
+ tag.set_y(spaces.b.y1("plot_tag"))
888
+ tag.set_verticalalignment("bottom")
889
+ if "left" in position:
890
+ tag.set_x(spaces.l.x1("plot_tag"))
891
+ tag.set_horizontalalignment("left")
892
+ if "right" in position:
893
+ tag.set_x(spaces.r.x2("plot_tag"))
894
+ tag.set_horizontalalignment("right")
895
+
896
+
897
+ def _plot_tag_margin_adjustment(
898
+ margin: Margin, position: str
899
+ ) -> tuple[float, float]:
900
+ """
901
+ How to adjust the plot_tag to account for the margin
902
+ """
903
+ m = margin.fig
904
+ dx, dy = 0, 0
905
+
906
+ if "top" in position:
907
+ dy = -m.t
908
+ elif "bottom" in position:
909
+ dy = m.b
910
+
911
+ if "left" in position:
912
+ dx = m.l
913
+ elif "right" in position:
914
+ dx = -m.r
915
+
916
+ return (dx, dy)