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,1007 @@
1
+ """
2
+ Routines to adjust subplot params so that subplots are
3
+ nicely fit in the figure. In doing so, only axis labels, tick labels, axes
4
+ titles and offsetboxes that are anchored to axes are currently considered.
5
+
6
+ Internally, this module assumes that the margins (left margin, etc.) which are
7
+ differences between `Axes.get_tightbbox` and `Axes.bbox` are independent of
8
+ Axes position. This may fail if `Axes.adjustable` is `datalim` as well as
9
+ such cases as when left or right margin are affected by xlabel.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from abc import ABC
15
+ from dataclasses import dataclass, field, fields
16
+ from functools import cached_property
17
+ from typing import TYPE_CHECKING
18
+
19
+ from plotnine.facets import facet_grid, facet_null, facet_wrap
20
+
21
+ from ._layout_items import LayoutItems
22
+
23
+ if TYPE_CHECKING:
24
+ from dataclasses import Field
25
+ from typing import Generator
26
+
27
+ from plotnine import ggplot
28
+ from plotnine._mpl.gridspec import p9GridSpec
29
+
30
+
31
+ # Note
32
+ # Margins around the plot are specified in figure coordinates
33
+ # We interpret that value to be a fraction of the width. So along
34
+ # the vertical direction we multiply by W/H to get equal space
35
+ # in both directions
36
+
37
+
38
+ @dataclass
39
+ class GridSpecParams:
40
+ """
41
+ Gridspec Parameters
42
+ """
43
+
44
+ left: float
45
+ right: float
46
+ top: float
47
+ bottom: float
48
+ wspace: float
49
+ hspace: float
50
+
51
+ @property
52
+ def valid(self) -> bool:
53
+ """
54
+ Return True if the params will create a non-empty area
55
+ """
56
+ return self.top - self.bottom > 0 and self.right - self.left > 0
57
+
58
+
59
+ @dataclass
60
+ class _side_spaces(ABC):
61
+ """
62
+ Base class to for spaces
63
+
64
+ A *_space class does the book keeping for all the artists that may
65
+ fall on that side of the panels. The same name may appear in multiple
66
+ side classes (e.g. legend).
67
+
68
+ The amount of space for each artist is computed in figure coordinates.
69
+ """
70
+
71
+ items: LayoutItems
72
+ alignment_margin: float = 0
73
+ """
74
+ A margin added to align this plot with others in a composition
75
+
76
+ This value is calculated during the layout process in a tree structure
77
+ that has convenient access to the sides/edges of the panels in the
78
+ composition.
79
+ """
80
+
81
+ def __post_init__(self):
82
+ self._calculate()
83
+
84
+ def _calculate(self):
85
+ """
86
+ Calculate the space taken up by each artist
87
+ """
88
+
89
+ @property
90
+ def total(self) -> float:
91
+ """
92
+ Total space
93
+ """
94
+ return sum(getattr(self, f.name) for f in fields(self)[1:])
95
+
96
+ def sum_upto(self, item: str) -> float:
97
+ """
98
+ Sum of space upto but not including item
99
+
100
+ Sums from the edge of the figure i.e. the "plot_margin".
101
+ """
102
+
103
+ def _fields_upto(item: str) -> Generator[Field, None, None]:
104
+ for f in fields(self)[1:]:
105
+ if f.name == item:
106
+ break
107
+ yield f
108
+
109
+ return sum(getattr(self, f.name) for f in _fields_upto(item))
110
+
111
+ def sum_incl(self, item: str) -> float:
112
+ """
113
+ Sum of space upto and including the item
114
+
115
+ Sums from the edge of the figure i.e. the "plot_margin".
116
+ """
117
+
118
+ def _fields_upto(item: str) -> Generator[Field, None, None]:
119
+ for f in fields(self)[1:]:
120
+ yield f
121
+ if f.name == item:
122
+ break
123
+
124
+ return sum(getattr(self, f.name) for f in _fields_upto(item))
125
+
126
+ @cached_property
127
+ def _legend_size(self) -> tuple[float, float]:
128
+ """
129
+ Return size of legend in figure coordinates
130
+
131
+ We need this to accurately justify the legend by proportional
132
+ values e.g. 0.2, instead of just left, right, top, bottom &
133
+ center.
134
+ """
135
+ return (0, 0)
136
+
137
+ @cached_property
138
+ def _legend_width(self) -> float:
139
+ """
140
+ Return width of legend in figure coordinates
141
+ """
142
+ return self._legend_size[0]
143
+
144
+ @cached_property
145
+ def _legend_height(self) -> float:
146
+ """
147
+ Return height of legend in figure coordinates
148
+ """
149
+ return self._legend_size[1]
150
+
151
+ @cached_property
152
+ def gs(self) -> p9GridSpec:
153
+ """
154
+ The gridspec of the plot
155
+ """
156
+ return self.items.plot._gridspec
157
+
158
+ @property
159
+ def offset(self) -> float:
160
+ """
161
+ Distance in figure dimensions from the edge of the figure
162
+
163
+ Derived classes should override this method
164
+
165
+ The space/margin and size consumed by artists is in figure dimensions
166
+ but the exact position is relative to the position of the GridSpec
167
+ within the figure. The offset accounts for the position of the
168
+ GridSpec and allows us to accurately place artists using figure
169
+ coordinates.
170
+
171
+ Example of an offset
172
+
173
+ Figure
174
+ ----------------------------------------
175
+ | |
176
+ | Plot GridSpec |
177
+ | -------------------------- |
178
+ | offset | | |
179
+ |<------->| X | |
180
+ | | Panels GridSpec | |
181
+ | | -------------------- | |
182
+ | | | | | |
183
+ | | | | | |
184
+ | | | | | |
185
+ | | | | | |
186
+ | | -------------------- | |
187
+ | | | |
188
+ | -------------------------- |
189
+ | |
190
+ ----------------------------------------
191
+ """
192
+ return 0
193
+
194
+ def to_figure_space(self, rel_value: float) -> float:
195
+ """
196
+ Convert value relative to the gridspec to one in figure space
197
+
198
+ The result is meant to be used with transFigure transforms.
199
+
200
+ Parameters
201
+ ----------
202
+ rel_value :
203
+ Position relative to the position of the gridspec
204
+ """
205
+ return self.offset + rel_value
206
+
207
+
208
+ @dataclass
209
+ class left_spaces(_side_spaces):
210
+ """
211
+ Space in the figure for artists on the left of the panel area
212
+
213
+ Ordered from the edge of the figure and going inwards
214
+ """
215
+
216
+ plot_margin: float = 0
217
+ plot_tag_margin_left: float = 0
218
+ plot_tag: float = 0
219
+ plot_tag_margin_right: float = 0
220
+ legend: float = 0
221
+ legend_box_spacing: float = 0
222
+ axis_title_y_margin_left: float = 0
223
+ axis_title_y: float = 0
224
+ axis_title_y_margin_right: float = 0
225
+ axis_text_y: float = 0
226
+ axis_ticks_y: float = 0
227
+
228
+ def _calculate(self):
229
+ theme = self.items.plot.theme
230
+ calc = self.items.calc
231
+ items = self.items
232
+
233
+ # If the plot_tag is in the margin, it is included in the layout.
234
+ # So we make space for it, including any margins it may have.
235
+ plot_tag_in_layout = theme.getp(
236
+ "plot_tag_location"
237
+ ) == "margin" and "left" in theme.getp("plot_tag_position")
238
+
239
+ self.plot_margin = theme.getp("plot_margin_left")
240
+
241
+ if items.plot_tag and plot_tag_in_layout:
242
+ m = theme.get_margin("plot_tag").fig
243
+ self.plot_tag_margin_left = m.l
244
+ self.plot_tag = calc.width(items.plot_tag)
245
+ self.plot_tag_margin_right = m.r
246
+
247
+ if items.legends and items.legends.left:
248
+ self.legend = self._legend_width
249
+ self.legend_box_spacing = theme.getp("legend_box_spacing")
250
+
251
+ if items.axis_title_y:
252
+ m = theme.get_margin("axis_title_y").fig
253
+ self.axis_title_y_margin_left = m.l
254
+ self.axis_title_y = calc.width(items.axis_title_y)
255
+ self.axis_title_y_margin_right = m.r
256
+
257
+ # Account for the space consumed by the axis
258
+ self.axis_text_y = items.axis_text_y_max_width_at("first_col")
259
+ self.axis_ticks_y = items.axis_ticks_y_max_width_at("first_col")
260
+
261
+ # Adjust plot_margin to make room for ylabels that protude well
262
+ # beyond the axes
263
+ # NOTE: This adjustment breaks down when the protrusion is large
264
+ protrusion = items.axis_text_x_left_protrusion("all")
265
+ adjustment = protrusion - (self.total - self.plot_margin)
266
+ if adjustment > 0:
267
+ self.plot_margin += adjustment
268
+
269
+ @cached_property
270
+ def _legend_size(self) -> tuple[float, float]:
271
+ if not (self.items.legends and self.items.legends.left):
272
+ return (0, 0)
273
+
274
+ return self.items.calc.size(self.items.legends.left.box)
275
+
276
+ @property
277
+ def offset(self) -> float:
278
+ """
279
+ Distance from left of the figure to the left of the plot gridspec
280
+
281
+ ----------------(1, 1)
282
+ | ---- |
283
+ | dx | | |
284
+ |<--->| | |
285
+ | | | |
286
+ | ---- |
287
+ (0, 0)----------------
288
+
289
+ """
290
+ return self.gs.bbox_relative.x0
291
+
292
+ def x1(self, item: str) -> float:
293
+ """
294
+ Lower x-coordinate in figure space of the item
295
+ """
296
+ return self.to_figure_space(self.sum_upto(item))
297
+
298
+ def x2(self, item: str) -> float:
299
+ """
300
+ Higher x-coordinate in figure space of the item
301
+ """
302
+ return self.to_figure_space(self.sum_incl(item))
303
+
304
+ @property
305
+ def left_relative(self):
306
+ """
307
+ Left (relative to the gridspec) of the panels in figure dimensions
308
+ """
309
+ return self.total
310
+
311
+ @property
312
+ def left(self):
313
+ """
314
+ Left of the panels in figure space
315
+ """
316
+ return self.to_figure_space(self.left_relative)
317
+
318
+ @property
319
+ def plot_left(self):
320
+ """
321
+ Distance up to the left-most artist in figure space
322
+ """
323
+ return self.x1("legend")
324
+
325
+
326
+ @dataclass
327
+ class right_spaces(_side_spaces):
328
+ """
329
+ Space in the figure for artists on the right of the panel area
330
+
331
+ Ordered from the edge of the figure and going inwards
332
+ """
333
+
334
+ plot_margin: float = 0
335
+ plot_tag_margin_right: float = 0
336
+ plot_tag: float = 0
337
+ plot_tag_margin_left: float = 0
338
+ legend: float = 0
339
+ legend_box_spacing: float = 0
340
+ strip_text_y_width_right: float = 0
341
+
342
+ def _calculate(self):
343
+ items = self.items
344
+ theme = self.items.plot.theme
345
+ calc = self.items.calc
346
+ # If the plot_tag is in the margin, it is included in the layout.
347
+ # So we make space for it, including any margins it may have.
348
+ plot_tag_in_layout = theme.getp(
349
+ "plot_tag_location"
350
+ ) == "margin" and "right" in theme.getp("plot_tag_position")
351
+
352
+ self.plot_margin = theme.getp("plot_margin_right")
353
+
354
+ if items.plot_tag and plot_tag_in_layout:
355
+ m = theme.get_margin("plot_tag").fig
356
+ self.plot_tag_margin_right = m.r
357
+ self.plot_tag = calc.width(items.plot_tag)
358
+ self.plot_tag_margin_left = m.l
359
+
360
+ if items.legends and items.legends.right:
361
+ self.legend = self._legend_width
362
+ self.legend_box_spacing = theme.getp("legend_box_spacing")
363
+
364
+ self.strip_text_y_width_right = items.strip_text_y_width("right")
365
+
366
+ # Adjust plot_margin to make room for ylabels that protude well
367
+ # beyond the axes
368
+ # NOTE: This adjustment breaks down when the protrusion is large
369
+ protrusion = items.axis_text_x_right_protrusion("all")
370
+ adjustment = protrusion - (self.total - self.plot_margin)
371
+ if adjustment > 0:
372
+ self.plot_margin += adjustment
373
+
374
+ @cached_property
375
+ def _legend_size(self) -> tuple[float, float]:
376
+ if not (self.items.legends and self.items.legends.right):
377
+ return (0, 0)
378
+
379
+ return self.items.calc.size(self.items.legends.right.box)
380
+
381
+ @property
382
+ def offset(self):
383
+ """
384
+ Distance from right of the figure to the right of the plot gridspec
385
+
386
+ ---------------(1, 1)
387
+ | ---- |
388
+ | | | -dx |
389
+ | | |<--->|
390
+ | | | |
391
+ | ---- |
392
+ (0, 0)---------------
393
+
394
+ """
395
+ return self.gs.bbox_relative.x1 - 1
396
+
397
+ def x1(self, item: str) -> float:
398
+ """
399
+ Lower x-coordinate in figure space of the item
400
+ """
401
+ return self.to_figure_space(1 - self.sum_incl(item))
402
+
403
+ def x2(self, item: str) -> float:
404
+ """
405
+ Higher x-coordinate in figure space of the item
406
+ """
407
+ return self.to_figure_space(1 - self.sum_upto(item))
408
+
409
+ @property
410
+ def right_relative(self):
411
+ """
412
+ Right (relative to the gridspec) of the panels in figure dimensions
413
+ """
414
+ return 1 - self.total
415
+
416
+ @property
417
+ def right(self):
418
+ """
419
+ Right of the panels in figure space
420
+ """
421
+ return self.to_figure_space(self.right_relative)
422
+
423
+ @property
424
+ def plot_right(self):
425
+ """
426
+ Distance up to the right-most artist in figure space
427
+ """
428
+ return self.x2("legend")
429
+
430
+
431
+ @dataclass
432
+ class top_spaces(_side_spaces):
433
+ """
434
+ Space in the figure for artists above the panel area
435
+
436
+ Ordered from the edge of the figure and going inwards
437
+ """
438
+
439
+ plot_margin: float = 0
440
+ plot_tag_margin_top: float = 0
441
+ plot_tag: float = 0
442
+ plot_tag_margin_bottom: float = 0
443
+ plot_title_margin_top: float = 0
444
+ plot_title: float = 0
445
+ plot_title_margin_bottom: float = 0
446
+ plot_subtitle_margin_top: float = 0
447
+ plot_subtitle: float = 0
448
+ plot_subtitle_margin_bottom: float = 0
449
+ legend: float = 0
450
+ legend_box_spacing: float = 0
451
+ strip_text_x_height_top: float = 0
452
+
453
+ def _calculate(self):
454
+ items = self.items
455
+ theme = self.items.plot.theme
456
+ calc = self.items.calc
457
+ W, H = theme.getp("figure_size")
458
+ F = W / H
459
+ # If the plot_tag is in the margin, it is included in the layout.
460
+ # So we make space for it, including any margins it may have.
461
+ plot_tag_in_layout = theme.getp(
462
+ "plot_tag_location"
463
+ ) == "margin" and "top" in theme.getp("plot_tag_position")
464
+
465
+ self.plot_margin = theme.getp("plot_margin_top") * F
466
+
467
+ if items.plot_tag and plot_tag_in_layout:
468
+ m = theme.get_margin("plot_tag").fig
469
+ self.plot_tag_margin_top = m.t
470
+ self.plot_tag = calc.height(items.plot_tag)
471
+ self.plot_tag_margin_bottom = m.b
472
+
473
+ if items.plot_title:
474
+ m = theme.get_margin("plot_title").fig
475
+ self.plot_title_margin_top = m.t * F
476
+ self.plot_title = calc.height(items.plot_title)
477
+ self.plot_title_margin_bottom = m.b * F
478
+
479
+ if items.plot_subtitle:
480
+ m = theme.get_margin("plot_subtitle").fig
481
+ self.plot_subtitle_margin_top = m.t * F
482
+ self.plot_subtitle = calc.height(items.plot_subtitle)
483
+ self.plot_subtitle_margin_bottom = m.b * F
484
+
485
+ if items.legends and items.legends.top:
486
+ self.legend = self._legend_height
487
+ self.legend_box_spacing = theme.getp("legend_box_spacing") * F
488
+
489
+ self.strip_text_x_height_top = items.strip_text_x_height("top")
490
+
491
+ # Adjust plot_margin to make room for ylabels that protude well
492
+ # beyond the axes
493
+ # NOTE: This adjustment breaks down when the protrusion is large
494
+ protrusion = items.axis_text_y_top_protrusion("all")
495
+ adjustment = protrusion - (self.total - self.plot_margin)
496
+ if adjustment > 0:
497
+ self.plot_margin += adjustment
498
+
499
+ @cached_property
500
+ def _legend_size(self) -> tuple[float, float]:
501
+ if not (self.items.legends and self.items.legends.top):
502
+ return (0, 0)
503
+
504
+ return self.items.calc.size(self.items.legends.top.box)
505
+
506
+ @property
507
+ def offset(self) -> float:
508
+ """
509
+ Distance from top of the figure to the top of the plot gridspec
510
+
511
+ ----------------(1, 1)
512
+ | ^ |
513
+ | |-dy |
514
+ | v |
515
+ | ---- |
516
+ | | | |
517
+ | | | |
518
+ | | | |
519
+ | ---- |
520
+ | |
521
+ (0, 0)----------------
522
+ """
523
+ return self.gs.bbox_relative.y1 - 1
524
+
525
+ def y1(self, item: str) -> float:
526
+ """
527
+ Lower y-coordinate in figure space of the item
528
+ """
529
+ return self.to_figure_space(1 - self.sum_incl(item))
530
+
531
+ def y2(self, item: str) -> float:
532
+ """
533
+ Higher y-coordinate in figure space of the item
534
+ """
535
+ return self.to_figure_space(1 - self.sum_upto(item))
536
+
537
+ @property
538
+ def top_relative(self):
539
+ """
540
+ Top (relative to the gridspec) of the panels in figure dimensions
541
+ """
542
+ return 1 - self.total
543
+
544
+ @property
545
+ def top(self):
546
+ """
547
+ Top of the panels in figure space
548
+ """
549
+ return self.to_figure_space(self.top_relative)
550
+
551
+ @property
552
+ def plot_top(self):
553
+ """
554
+ Distance up to the top-most artist in figure space
555
+ """
556
+ return self.y2("legend")
557
+
558
+
559
+ @dataclass
560
+ class bottom_spaces(_side_spaces):
561
+ """
562
+ Space in the figure for artists below the panel area
563
+
564
+ Ordered from the edge of the figure and going inwards
565
+ """
566
+
567
+ plot_margin: float = 0
568
+ plot_tag_margin_bottom: float = 0
569
+ plot_tag: float = 0
570
+ plot_tag_margin_top: float = 0
571
+ plot_caption_margin_bottom: float = 0
572
+ plot_caption: float = 0
573
+ plot_caption_margin_top: float = 0
574
+ legend: float = 0
575
+ legend_box_spacing: float = 0
576
+ axis_title_x_margin_bottom: float = 0
577
+ axis_title_x: float = 0
578
+ axis_title_x_margin_top: float = 0
579
+ axis_text_x: float = 0
580
+ axis_ticks_x: float = 0
581
+
582
+ def _calculate(self):
583
+ items = self.items
584
+ theme = self.items.plot.theme
585
+ calc = self.items.calc
586
+ W, H = theme.getp("figure_size")
587
+ F = W / H
588
+ # If the plot_tag is in the margin, it is included in the layout.
589
+ # So we make space for it, including any margins it may have.
590
+ plot_tag_in_layout = theme.getp(
591
+ "plot_tag_location"
592
+ ) == "margin" and "bottom" in theme.getp("plot_tag_position")
593
+
594
+ self.plot_margin = theme.getp("plot_margin_bottom") * F
595
+
596
+ if items.plot_tag and plot_tag_in_layout:
597
+ m = theme.get_margin("plot_tag").fig
598
+ self.plot_tag_margin_bottom = m.b
599
+ self.plot_tag = calc.height(items.plot_tag)
600
+ self.plot_tag_margin_top = m.t
601
+
602
+ if items.plot_caption:
603
+ m = theme.get_margin("plot_caption").fig
604
+ self.plot_caption_margin_bottom = m.b * F
605
+ self.plot_caption = calc.height(items.plot_caption)
606
+ self.plot_caption_margin_top = m.t * F
607
+
608
+ if items.legends and items.legends.bottom:
609
+ self.legend = self._legend_height
610
+ self.legend_box_spacing = theme.getp("legend_box_spacing") * F
611
+
612
+ if items.axis_title_x:
613
+ m = theme.get_margin("axis_title_x").fig
614
+ self.axis_title_x_margin_bottom = m.b * F
615
+ self.axis_title_x = calc.height(items.axis_title_x)
616
+ self.axis_title_x_margin_top = m.t * F
617
+
618
+ # Account for the space consumed by the axis
619
+ self.axis_ticks_x = items.axis_ticks_x_max_height_at("last_row")
620
+ self.axis_text_x = items.axis_text_x_max_height_at("last_row")
621
+
622
+ # Adjust plot_margin to make room for ylabels that protude well
623
+ # beyond the axes
624
+ # NOTE: This adjustment breaks down when the protrusion is large
625
+ protrusion = items.axis_text_y_bottom_protrusion("all")
626
+ adjustment = protrusion - (self.total - self.plot_margin)
627
+ if adjustment > 0:
628
+ self.plot_margin += adjustment
629
+
630
+ @cached_property
631
+ def _legend_size(self) -> tuple[float, float]:
632
+ if not (self.items.legends and self.items.legends.bottom):
633
+ return (0, 0)
634
+
635
+ return self.items.calc.size(self.items.legends.bottom.box)
636
+
637
+ @property
638
+ def offset(self) -> float:
639
+ """
640
+ Distance from bottom of the figure to the bottom of the plot gridspec
641
+
642
+ ----------------(1, 1)
643
+ | |
644
+ | ---- |
645
+ | | | |
646
+ | | | |
647
+ | | | |
648
+ | ---- |
649
+ | ^ |
650
+ | |dy |
651
+ | v |
652
+ (0, 0)----------------
653
+ """
654
+ return self.gs.bbox_relative.y0
655
+
656
+ def y1(self, item: str) -> float:
657
+ """
658
+ Lower y-coordinate in figure space of the item
659
+ """
660
+ return self.to_figure_space(self.sum_upto(item))
661
+
662
+ def y2(self, item: str) -> float:
663
+ """
664
+ Higher y-coordinate in figure space of the item
665
+ """
666
+ return self.to_figure_space(self.sum_incl(item))
667
+
668
+ @property
669
+ def bottom_relative(self):
670
+ """
671
+ Bottom (relative to the gridspec) of the panels in figure dimensions
672
+ """
673
+ return self.total
674
+
675
+ @property
676
+ def bottom(self):
677
+ """
678
+ Bottom of the panels in figure space
679
+ """
680
+ return self.to_figure_space(self.bottom_relative)
681
+
682
+ @property
683
+ def plot_bottom(self):
684
+ """
685
+ Distance up to the bottom-most artist in figure space
686
+ """
687
+ return self.y1("legend")
688
+
689
+
690
+ @dataclass
691
+ class LayoutSpaces:
692
+ """
693
+ Compute the all the spaces required in the layout
694
+
695
+ These are:
696
+
697
+ 1. The space of each artist between the panel and the edge of the
698
+ figure.
699
+ 2. The space in-between the panels
700
+
701
+ From these values, we put together the grid-spec parameters required
702
+ by matplotblib to position the axes. We also use the values to adjust
703
+ the coordinates of all the artists that occupy these spaces, placing
704
+ them in their final positions.
705
+ """
706
+
707
+ plot: ggplot
708
+
709
+ l: left_spaces = field(init=False)
710
+ """All subspaces to the left of the panels"""
711
+
712
+ r: right_spaces = field(init=False)
713
+ """All subspaces to the right of the panels"""
714
+
715
+ t: top_spaces = field(init=False)
716
+ """All subspaces above the top of the panels"""
717
+
718
+ b: bottom_spaces = field(init=False)
719
+ """All subspaces below the bottom of the panels"""
720
+
721
+ W: float = field(init=False, default=0)
722
+ """Figure Width [inches]"""
723
+
724
+ H: float = field(init=False, default=0)
725
+ """Figure Height [inches]"""
726
+
727
+ w: float = field(init=False, default=0)
728
+ """Axes width w.r.t figure in [0, 1]"""
729
+
730
+ h: float = field(init=False, default=0)
731
+ """Axes height w.r.t figure in [0, 1]"""
732
+
733
+ sh: float = field(init=False, default=0)
734
+ """horizontal spacing btn panels w.r.t figure"""
735
+
736
+ sw: float = field(init=False, default=0)
737
+ """vertical spacing btn panels w.r.t figure"""
738
+
739
+ gsparams: GridSpecParams = field(init=False)
740
+ """Grid spacing btn panels w.r.t figure"""
741
+
742
+ def __post_init__(self):
743
+ self.items = LayoutItems(self.plot)
744
+ self.W, self.H = self.plot.theme.getp("figure_size")
745
+
746
+ # Calculate the spacing along the edges of the panel area
747
+ # (spacing required by plotnine)
748
+ self.l = left_spaces(self.items)
749
+ self.r = right_spaces(self.items)
750
+ self.t = top_spaces(self.items)
751
+ self.b = bottom_spaces(self.items)
752
+
753
+ def get_gridspec_params(self) -> GridSpecParams:
754
+ # Calculate the gridspec params
755
+ # (spacing required by mpl)
756
+ self.gsparams = self._calculate_panel_spacing()
757
+
758
+ # Adjust the spacing parameters for the desired aspect ratio
759
+ # It is simpler to adjust for the aspect ratio than to calculate
760
+ # the final parameters that are true to the aspect ratio in
761
+ # one-short
762
+ if (ratio := self.plot.facet._aspect_ratio()) is not None:
763
+ current_ratio = self.aspect_ratio
764
+ if ratio > current_ratio:
765
+ # Increase aspect ratio, taller panels
766
+ self._reduce_width(ratio)
767
+ elif ratio < current_ratio:
768
+ # Increase aspect ratio, wider panels
769
+ self._reduce_height(ratio)
770
+
771
+ return self.gsparams
772
+
773
+ @property
774
+ def plot_width(self) -> float:
775
+ """
776
+ Width [figure dimensions] of the whole plot
777
+ """
778
+ return self.plot._gridspec.bbox_relative.width
779
+
780
+ @property
781
+ def plot_height(self) -> float:
782
+ """
783
+ Height [figure dimensions] of the whole plot
784
+ """
785
+ return self.plot._gridspec.bbox_relative.height
786
+
787
+ @property
788
+ def panel_width(self) -> float:
789
+ """
790
+ Width [figure dimensions] of panels
791
+ """
792
+ return self.r.right - self.l.left
793
+
794
+ @property
795
+ def panel_height(self) -> float:
796
+ """
797
+ Height [figure dimensions] of panels
798
+ """
799
+ return self.t.top - self.b.bottom
800
+
801
+ def increase_horizontal_plot_margin(self, dw: float):
802
+ """
803
+ Increase the plot_margin to the right & left of the panels
804
+ """
805
+ self.l.plot_margin += dw
806
+ self.r.plot_margin += dw
807
+
808
+ def increase_vertical_plot_margin(self, dh: float):
809
+ """
810
+ Increase the plot_margin to the above & below of the panels
811
+ """
812
+ self.t.plot_margin += dh
813
+ self.b.plot_margin += dh
814
+
815
+ @property
816
+ def plot_area_coordinates(
817
+ self,
818
+ ) -> tuple[tuple[float, float], tuple[float, float]]:
819
+ """
820
+ Lower-left and upper-right coordinates of the plot area
821
+
822
+ This is the area surrounded by the plot_margin.
823
+ """
824
+ x1, x2 = self.l.x2("plot_margin"), self.r.x1("plot_margin")
825
+ y1, y2 = self.b.y2("plot_margin"), self.t.y1("plot_margin")
826
+ return ((x1, y1), (x2, y2))
827
+
828
+ @property
829
+ def panel_area_coordinates(
830
+ self,
831
+ ) -> tuple[tuple[float, float], tuple[float, float]]:
832
+ """
833
+ Lower-left and upper-right coordinates of the panel area
834
+
835
+ This is the area in which the panels are drawn.
836
+ """
837
+ x1, x2 = self.l.left, self.r.right
838
+ y1, y2 = self.b.bottom, self.t.top
839
+ return ((x1, y1), (x2, y2))
840
+
841
+ def _calculate_panel_spacing(self) -> GridSpecParams:
842
+ """
843
+ Spacing between the panels (wspace & hspace)
844
+
845
+ Both spaces are calculated from a fraction of the width.
846
+ This ensures that the same fraction gives equals space
847
+ in both directions.
848
+ """
849
+ if isinstance(self.plot.facet, facet_wrap):
850
+ wspace, hspace = self._calculate_panel_spacing_facet_wrap()
851
+ elif isinstance(self.plot.facet, facet_grid):
852
+ wspace, hspace = self._calculate_panel_spacing_facet_grid()
853
+ elif isinstance(self.plot.facet, facet_null):
854
+ wspace, hspace = self._calculate_panel_spacing_facet_null()
855
+ else:
856
+ raise TypeError(f"Unknown type of facet: {type(self.plot.facet)}")
857
+
858
+ return GridSpecParams(
859
+ self.l.left_relative,
860
+ self.r.right_relative,
861
+ self.t.top_relative,
862
+ self.b.bottom_relative,
863
+ wspace,
864
+ hspace,
865
+ )
866
+
867
+ def _calculate_panel_spacing_facet_grid(self) -> tuple[float, float]:
868
+ """
869
+ Calculate spacing parts for facet_grid
870
+ """
871
+ theme = self.plot.theme
872
+
873
+ ncol = self.plot.facet.ncol
874
+ nrow = self.plot.facet.nrow
875
+
876
+ # Both spacings are specified as fractions of the figure width
877
+ # Multiply the vertical by (W/H) so that the gullies along both
878
+ # directions are equally spaced.
879
+ self.sw = theme.getp("panel_spacing_x")
880
+ self.sh = theme.getp("panel_spacing_y") * self.W / self.H
881
+
882
+ # width and height of axes as fraction of figure width & height
883
+ self.w = ((self.r.right - self.l.left) - self.sw * (ncol - 1)) / ncol
884
+ self.h = ((self.t.top - self.b.bottom) - self.sh * (nrow - 1)) / nrow
885
+
886
+ # Spacing as fraction of axes width & height
887
+ wspace = self.sw / self.w
888
+ hspace = self.sh / self.h
889
+ return (wspace, hspace)
890
+
891
+ def _calculate_panel_spacing_facet_wrap(self) -> tuple[float, float]:
892
+ """
893
+ Calculate spacing parts for facet_wrap
894
+ """
895
+ facet = self.plot.facet
896
+ theme = self.plot.theme
897
+
898
+ ncol = facet.ncol
899
+ nrow = facet.nrow
900
+
901
+ # Both spacings are specified as fractions of the figure width
902
+ self.sw = theme.getp("panel_spacing_x")
903
+ self.sh = theme.getp("panel_spacing_y") * self.W / self.H
904
+
905
+ # A fraction of the strip height
906
+ # Effectively slides the strip
907
+ # +ve: Away from the panel
908
+ # 0: Top of the panel
909
+ # -ve: Into the panel
910
+ # Where values <= -1, put the strip completely into
911
+ # the panel. We do not worry about larger -ves.
912
+ strip_align_x = theme.getp("strip_align_x")
913
+
914
+ # Only interested in the proportion of the strip that
915
+ # does not overlap with the panel
916
+ if strip_align_x > -1:
917
+ self.sh += self.t.strip_text_x_height_top * (1 + strip_align_x)
918
+
919
+ if facet.free["x"]:
920
+ self.sh += self.items.axis_text_x_max_height_at(
921
+ "all"
922
+ ) + self.items.axis_ticks_x_max_height_at("all")
923
+ if facet.free["y"]:
924
+ self.sw += self.items.axis_text_y_max_width_at(
925
+ "all"
926
+ ) + self.items.axis_ticks_y_max_width_at("all")
927
+
928
+ # width and height of axes as fraction of figure width & height
929
+ self.w = ((self.r.right - self.l.left) - self.sw * (ncol - 1)) / ncol
930
+ self.h = ((self.t.top - self.b.bottom) - self.sh * (nrow - 1)) / nrow
931
+
932
+ # Spacing as fraction of axes width & height
933
+ wspace = self.sw / self.w
934
+ hspace = self.sh / self.h
935
+ return (wspace, hspace)
936
+
937
+ def _calculate_panel_spacing_facet_null(self) -> tuple[float, float]:
938
+ """
939
+ Calculate spacing parts for facet_null
940
+ """
941
+ self.w = self.r.right - self.l.left
942
+ self.h = self.t.top - self.b.bottom
943
+ self.sw = 0
944
+ self.sh = 0
945
+ return 0, 0
946
+
947
+ def _reduce_height(self, ratio: float):
948
+ """
949
+ Reduce the height of axes to get the aspect ratio
950
+ """
951
+ # New height w.r.t figure height
952
+ h1 = ratio * self.w * (self.W / self.H)
953
+
954
+ # Half of the total vertical reduction w.r.t figure height
955
+ dh = (self.h - h1) * self.plot.facet.nrow / 2
956
+
957
+ # Reduce plot area height
958
+ self.gsparams.top -= dh
959
+ self.gsparams.bottom += dh
960
+ self.gsparams.hspace = self.sh / h1
961
+
962
+ # Add more vertical plot margin
963
+ self.increase_vertical_plot_margin(dh)
964
+
965
+ def _reduce_width(self, ratio: float):
966
+ """
967
+ Reduce the width of axes to get the aspect ratio
968
+ """
969
+ # New width w.r.t figure width
970
+ w1 = (self.h * self.H) / (ratio * self.W)
971
+
972
+ # Half of the total horizontal reduction w.r.t figure width
973
+ dw = (self.w - w1) * self.plot.facet.ncol / 2
974
+
975
+ # Reduce width
976
+ self.gsparams.left += dw
977
+ self.gsparams.right -= dw
978
+ self.gsparams.wspace = self.sw / w1
979
+
980
+ # Add more horizontal margin
981
+ self.increase_horizontal_plot_margin(dw)
982
+
983
+ @property
984
+ def aspect_ratio(self) -> float:
985
+ """
986
+ Default aspect ratio of the panels
987
+ """
988
+ return (self.h * self.H) / (self.w * self.W)
989
+
990
+ @cached_property
991
+ def gs(self) -> p9GridSpec:
992
+ """
993
+ The gridspec
994
+ """
995
+ return self.plot._gridspec
996
+
997
+ def to_figure_space(
998
+ self,
999
+ position: tuple[float, float],
1000
+ ) -> tuple[float, float]:
1001
+ """
1002
+ Convert position from gridspec space to figure space
1003
+ """
1004
+ _x, _y = position
1005
+ x = self.l.plot_left + (self.r.plot_right - self.l.plot_left) * _x
1006
+ y = self.b.plot_bottom + (self.t.plot_top - self.b.plot_bottom) * _y
1007
+ return (x, y)