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