plotnine 0.15.3__py3-none-any.whl → 0.16.0a1__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/_mpl/gridspec.py +50 -6
- plotnine/_mpl/layout_manager/__init__.py +2 -5
- plotnine/_mpl/layout_manager/_composition_layout_items.py +98 -0
- plotnine/_mpl/layout_manager/_composition_side_space.py +461 -0
- plotnine/_mpl/layout_manager/_engine.py +19 -58
- plotnine/_mpl/layout_manager/_grid.py +94 -0
- plotnine/_mpl/layout_manager/_layout_tree.py +402 -817
- plotnine/_mpl/layout_manager/{_layout_items.py → _plot_layout_items.py} +55 -278
- plotnine/_mpl/layout_manager/{_spaces.py → _plot_side_space.py} +111 -291
- plotnine/_mpl/layout_manager/_side_space.py +176 -0
- plotnine/_mpl/utils.py +259 -1
- plotnine/_utils/__init__.py +23 -3
- plotnine/_utils/context.py +9 -13
- plotnine/_utils/dataclasses.py +24 -0
- plotnine/animation.py +13 -12
- plotnine/composition/__init__.py +6 -0
- plotnine/composition/_beside.py +13 -11
- plotnine/composition/_compose.py +263 -99
- plotnine/composition/_plot_annotation.py +75 -0
- plotnine/composition/_plot_layout.py +143 -0
- plotnine/composition/_plot_spacer.py +1 -1
- plotnine/composition/_stack.py +13 -11
- plotnine/composition/_types.py +28 -0
- plotnine/composition/_wrap.py +60 -0
- plotnine/facets/facet.py +9 -12
- plotnine/facets/facet_grid.py +2 -2
- plotnine/facets/facet_wrap.py +1 -1
- plotnine/geoms/geom.py +2 -2
- plotnine/geoms/geom_map.py +4 -5
- plotnine/geoms/geom_path.py +8 -7
- plotnine/geoms/geom_rug.py +6 -10
- plotnine/geoms/geom_text.py +5 -5
- plotnine/ggplot.py +63 -9
- plotnine/guides/guide.py +24 -6
- plotnine/guides/guide_colorbar.py +88 -46
- plotnine/guides/guide_legend.py +47 -20
- plotnine/guides/guides.py +2 -2
- plotnine/iapi.py +17 -1
- plotnine/scales/scale.py +1 -1
- plotnine/stats/binning.py +15 -43
- plotnine/stats/smoothers.py +7 -3
- plotnine/stats/stat.py +2 -2
- plotnine/stats/stat_density_2d.py +10 -6
- plotnine/stats/stat_pointdensity.py +8 -1
- plotnine/stats/stat_qq.py +5 -5
- plotnine/stats/stat_qq_line.py +6 -1
- plotnine/stats/stat_sina.py +19 -20
- plotnine/stats/stat_summary.py +4 -2
- plotnine/stats/stat_summary_bin.py +7 -1
- plotnine/themes/elements/element_line.py +2 -0
- plotnine/themes/elements/element_text.py +12 -1
- plotnine/themes/theme.py +18 -24
- plotnine/themes/themeable.py +17 -3
- plotnine/typing.py +6 -1
- {plotnine-0.15.3.dist-info → plotnine-0.16.0a1.dist-info}/METADATA +3 -3
- {plotnine-0.15.3.dist-info → plotnine-0.16.0a1.dist-info}/RECORD +59 -51
- {plotnine-0.15.3.dist-info → plotnine-0.16.0a1.dist-info}/WHEEL +1 -1
- plotnine/composition/_plotspec.py +0 -50
- {plotnine-0.15.3.dist-info → plotnine-0.16.0a1.dist-info}/licenses/LICENSE +0 -0
- {plotnine-0.15.3.dist-info → plotnine-0.16.0a1.dist-info}/top_level.txt +0 -0
plotnine/ggplot.py
CHANGED
|
@@ -13,6 +13,7 @@ from typing import (
|
|
|
13
13
|
Iterable,
|
|
14
14
|
Optional,
|
|
15
15
|
cast,
|
|
16
|
+
overload,
|
|
16
17
|
)
|
|
17
18
|
from warnings import warn
|
|
18
19
|
|
|
@@ -52,6 +53,7 @@ if TYPE_CHECKING:
|
|
|
52
53
|
|
|
53
54
|
from plotnine import watermark
|
|
54
55
|
from plotnine._mpl.gridspec import p9GridSpec
|
|
56
|
+
from plotnine._mpl.layout_manager._plot_side_space import PlotSideSpaces
|
|
55
57
|
from plotnine.composition import Compose
|
|
56
58
|
from plotnine.coords.coord import coord
|
|
57
59
|
from plotnine.facets.facet import facet
|
|
@@ -104,6 +106,31 @@ class ggplot:
|
|
|
104
106
|
figure: Figure
|
|
105
107
|
axs: list[Axes]
|
|
106
108
|
_gridspec: p9GridSpec
|
|
109
|
+
"""
|
|
110
|
+
Gridspec (1x1) that contains the whole plot
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
_sub_gridspec: p9GridSpec
|
|
114
|
+
"""
|
|
115
|
+
Gridspec (nxn) that contains the facet panels
|
|
116
|
+
|
|
117
|
+
-------------------------
|
|
118
|
+
| title |<----- ._gridspec
|
|
119
|
+
| subtitle |
|
|
120
|
+
| |
|
|
121
|
+
| ------------- |
|
|
122
|
+
| | | |<-------+------ ._sub_gridspec
|
|
123
|
+
| | | | |
|
|
124
|
+
| | | | legend |
|
|
125
|
+
| ------------- |
|
|
126
|
+
| axis_ticks |
|
|
127
|
+
| axis_text |
|
|
128
|
+
| axis_title |
|
|
129
|
+
| caption |
|
|
130
|
+
-------------------------
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
_sidespaces: PlotSideSpaces
|
|
107
134
|
|
|
108
135
|
def __init__(
|
|
109
136
|
self,
|
|
@@ -128,7 +155,7 @@ class ggplot:
|
|
|
128
155
|
self.watermarks: list[watermark] = []
|
|
129
156
|
|
|
130
157
|
# build artefacts
|
|
131
|
-
self._build_objs = NS()
|
|
158
|
+
self._build_objs = NS(meta={})
|
|
132
159
|
|
|
133
160
|
def __str__(self) -> str:
|
|
134
161
|
"""
|
|
@@ -230,10 +257,19 @@ class ggplot:
|
|
|
230
257
|
other.__radd__(self)
|
|
231
258
|
return self
|
|
232
259
|
|
|
260
|
+
@overload
|
|
233
261
|
def __add__(
|
|
234
262
|
self,
|
|
235
263
|
rhs: PlotAddable | list[PlotAddable] | None,
|
|
236
|
-
) -> ggplot:
|
|
264
|
+
) -> ggplot: ...
|
|
265
|
+
|
|
266
|
+
@overload
|
|
267
|
+
def __add__(self, rhs: ggplot) -> Compose: ...
|
|
268
|
+
|
|
269
|
+
def __add__(
|
|
270
|
+
self,
|
|
271
|
+
rhs: PlotAddable | list[PlotAddable] | None | ggplot,
|
|
272
|
+
) -> ggplot | Compose:
|
|
237
273
|
"""
|
|
238
274
|
Add to ggplot
|
|
239
275
|
|
|
@@ -243,7 +279,15 @@ class ggplot:
|
|
|
243
279
|
Either an object that knows how to "radd"
|
|
244
280
|
itself to a ggplot, or a list of such objects.
|
|
245
281
|
"""
|
|
282
|
+
from .composition import Compose
|
|
283
|
+
|
|
246
284
|
self = deepcopy(self)
|
|
285
|
+
|
|
286
|
+
if isinstance(rhs, (ggplot, Compose)):
|
|
287
|
+
from .composition import Wrap
|
|
288
|
+
|
|
289
|
+
return Wrap([self, rhs])
|
|
290
|
+
|
|
247
291
|
return self.__iadd__(rhs)
|
|
248
292
|
|
|
249
293
|
def __or__(self, rhs: ggplot | Compose) -> Compose:
|
|
@@ -306,9 +350,14 @@ class ggplot:
|
|
|
306
350
|
self._build()
|
|
307
351
|
|
|
308
352
|
# setup
|
|
309
|
-
self.axs = self.facet.setup(self)
|
|
353
|
+
self._sub_gridspec, self.axs = self.facet.setup(self)
|
|
310
354
|
self.guides._setup(self)
|
|
311
|
-
self.theme.
|
|
355
|
+
self.theme._setup(
|
|
356
|
+
figure,
|
|
357
|
+
self.axs,
|
|
358
|
+
self.labels.title,
|
|
359
|
+
self.labels.subtitle,
|
|
360
|
+
)
|
|
312
361
|
|
|
313
362
|
# Drawing
|
|
314
363
|
self._draw_layers()
|
|
@@ -317,7 +366,7 @@ class ggplot:
|
|
|
317
366
|
self.guides.draw()
|
|
318
367
|
self._draw_figure_texts()
|
|
319
368
|
self._draw_watermarks()
|
|
320
|
-
self.
|
|
369
|
+
self._draw_plot_background()
|
|
321
370
|
|
|
322
371
|
# Artist object theming
|
|
323
372
|
self.theme.apply()
|
|
@@ -329,9 +378,7 @@ class ggplot:
|
|
|
329
378
|
"""
|
|
330
379
|
Setup this instance for the building process
|
|
331
380
|
"""
|
|
332
|
-
|
|
333
|
-
self._create_figure()
|
|
334
|
-
|
|
381
|
+
self._create_figure()
|
|
335
382
|
self.labels.add_defaults(self.mapping.labels)
|
|
336
383
|
return self.figure
|
|
337
384
|
|
|
@@ -339,6 +386,9 @@ class ggplot:
|
|
|
339
386
|
"""
|
|
340
387
|
Create gridspec for the panels
|
|
341
388
|
"""
|
|
389
|
+
if hasattr(self, "figure"):
|
|
390
|
+
return
|
|
391
|
+
|
|
342
392
|
import matplotlib.pyplot as plt
|
|
343
393
|
|
|
344
394
|
from ._mpl.gridspec import p9GridSpec
|
|
@@ -536,7 +586,7 @@ class ggplot:
|
|
|
536
586
|
for wm in self.watermarks:
|
|
537
587
|
wm.draw(self.figure)
|
|
538
588
|
|
|
539
|
-
def
|
|
589
|
+
def _draw_plot_background(self):
|
|
540
590
|
from matplotlib.patches import Rectangle
|
|
541
591
|
|
|
542
592
|
rect = Rectangle((0, 0), 0, 0, facecolor="none", zorder=-1000)
|
|
@@ -577,6 +627,9 @@ class ggplot:
|
|
|
577
627
|
This method has the same arguments as [](`~plotnine.ggplot.save`).
|
|
578
628
|
Use it to get access to the figure that will be saved.
|
|
579
629
|
"""
|
|
630
|
+
if format is None and isinstance(filename, (str, Path)):
|
|
631
|
+
format = str(filename).split(".")[-1]
|
|
632
|
+
|
|
580
633
|
fig_kwargs: Dict[str, Any] = {"format": format, **kwargs}
|
|
581
634
|
|
|
582
635
|
if limitsize is None:
|
|
@@ -626,6 +679,7 @@ class ggplot:
|
|
|
626
679
|
if dpi is not None:
|
|
627
680
|
self.theme = self.theme + theme(dpi=dpi)
|
|
628
681
|
|
|
682
|
+
self._build_objs.meta["figure_format"] = format
|
|
629
683
|
figure = self.draw(show=False)
|
|
630
684
|
return mpl_save_view(figure, fig_kwargs)
|
|
631
685
|
|
plotnine/guides/guide.py
CHANGED
|
@@ -11,13 +11,14 @@ from .._utils.registry import Register
|
|
|
11
11
|
from ..themes.theme import theme as Theme
|
|
12
12
|
|
|
13
13
|
if TYPE_CHECKING:
|
|
14
|
-
from typing import Literal, Optional, TypeAlias
|
|
14
|
+
from typing import Literal, Optional, Sequence, TypeAlias
|
|
15
15
|
|
|
16
16
|
import pandas as pd
|
|
17
17
|
from matplotlib.offsetbox import PackerBase
|
|
18
18
|
from typing_extensions import Self
|
|
19
19
|
|
|
20
20
|
from plotnine import aes, guides
|
|
21
|
+
from plotnine.iapi import guide_text
|
|
21
22
|
from plotnine.layer import Layers, layer
|
|
22
23
|
from plotnine.scales.scale import scale
|
|
23
24
|
from plotnine.typing import (
|
|
@@ -113,7 +114,7 @@ class guide(ABC, metaclass=Register):
|
|
|
113
114
|
# guide theme has priority and its targets are tracked
|
|
114
115
|
# independently.
|
|
115
116
|
self.theme = guides.plot.theme + self.theme
|
|
116
|
-
self.theme.
|
|
117
|
+
self.theme._setup(guides.plot.figure)
|
|
117
118
|
self.plot_layers = guides.plot.layers
|
|
118
119
|
self.plot_mapping = guides.plot.mapping
|
|
119
120
|
self.elements = self._elements_cls(self.theme, self)
|
|
@@ -139,6 +140,13 @@ class guide(ABC, metaclass=Register):
|
|
|
139
140
|
just = cast("tuple[float, float]", just)
|
|
140
141
|
return (pos, just)
|
|
141
142
|
|
|
143
|
+
@property
|
|
144
|
+
def num_breaks(self) -> int:
|
|
145
|
+
"""
|
|
146
|
+
Number of breaks
|
|
147
|
+
"""
|
|
148
|
+
return len(self.key)
|
|
149
|
+
|
|
142
150
|
def train(
|
|
143
151
|
self, scale: scale, aesthetic: Optional[str] = None
|
|
144
152
|
) -> Self | None:
|
|
@@ -148,6 +156,12 @@ class guide(ABC, metaclass=Register):
|
|
|
148
156
|
Returns guide if training is successful
|
|
149
157
|
"""
|
|
150
158
|
|
|
159
|
+
def merge(self, other: Self) -> Self:
|
|
160
|
+
"""
|
|
161
|
+
Merge with another guide
|
|
162
|
+
"""
|
|
163
|
+
return self
|
|
164
|
+
|
|
151
165
|
def draw(self) -> PackerBase:
|
|
152
166
|
"""
|
|
153
167
|
Draw guide
|
|
@@ -176,6 +190,10 @@ class GuideElements:
|
|
|
176
190
|
theme: Theme
|
|
177
191
|
guide: guide
|
|
178
192
|
|
|
193
|
+
@cached_property
|
|
194
|
+
def text(self) -> guide_text:
|
|
195
|
+
raise NotImplementedError
|
|
196
|
+
|
|
179
197
|
def __post_init__(self):
|
|
180
198
|
self.guide_kind = type(self.guide).__name__.split("_")[-1]
|
|
181
199
|
self._elements_cls = GuideElements
|
|
@@ -210,16 +228,16 @@ class GuideElements:
|
|
|
210
228
|
)
|
|
211
229
|
|
|
212
230
|
@cached_property
|
|
213
|
-
def
|
|
231
|
+
def text_positions(self) -> Sequence[Side]:
|
|
214
232
|
raise NotImplementedError
|
|
215
233
|
|
|
216
234
|
@cached_property
|
|
217
|
-
def _text_margin(self) -> float:
|
|
235
|
+
def _text_margin(self) -> Sequence[float]:
|
|
218
236
|
_margin = self.theme.getp(
|
|
219
237
|
(f"legend_text_{self.guide_kind}", "margin")
|
|
220
238
|
).pt
|
|
221
|
-
|
|
222
|
-
return getattr(_margin,
|
|
239
|
+
locs = (get_opposite_side(p)[0] for p in self.text_positions)
|
|
240
|
+
return [getattr(_margin, loc) for loc in locs]
|
|
223
241
|
|
|
224
242
|
@cached_property
|
|
225
243
|
def title_position(self) -> Side:
|
|
@@ -11,6 +11,8 @@ import numpy as np
|
|
|
11
11
|
import pandas as pd
|
|
12
12
|
from mizani.bounds import rescale
|
|
13
13
|
|
|
14
|
+
from plotnine.iapi import guide_text
|
|
15
|
+
|
|
14
16
|
from .._utils import get_opposite_side
|
|
15
17
|
from ..exceptions import PlotnineError, PlotnineWarning
|
|
16
18
|
from ..mapping.aes import rename_aesthetics
|
|
@@ -26,6 +28,7 @@ if TYPE_CHECKING:
|
|
|
26
28
|
from matplotlib.text import Text
|
|
27
29
|
|
|
28
30
|
from plotnine import theme
|
|
31
|
+
from plotnine.guides import guides
|
|
29
32
|
from plotnine.scales.scale import scale
|
|
30
33
|
from plotnine.typing import Side
|
|
31
34
|
|
|
@@ -48,7 +51,12 @@ class guide_colorbar(guide):
|
|
|
48
51
|
"""
|
|
49
52
|
|
|
50
53
|
display: Literal["gradient", "rectangles", "raster"] = "gradient"
|
|
51
|
-
"""
|
|
54
|
+
"""
|
|
55
|
+
How to render the colorbar
|
|
56
|
+
|
|
57
|
+
SVG figures will always use "rectangles" to create gradients. This has
|
|
58
|
+
better support across applications that render svg images.
|
|
59
|
+
"""
|
|
52
60
|
|
|
53
61
|
alpha: Optional[float] = None
|
|
54
62
|
"""
|
|
@@ -74,6 +82,12 @@ class guide_colorbar(guide):
|
|
|
74
82
|
if self.nbin is None:
|
|
75
83
|
self.nbin = 300 # if self.display == "gradient" else 300
|
|
76
84
|
|
|
85
|
+
def setup(self, guides: guides):
|
|
86
|
+
super().setup(guides)
|
|
87
|
+
# See: add_segmented_colorbar
|
|
88
|
+
if guides.plot._build_objs.meta.get("figure_format") == "svg":
|
|
89
|
+
self.display = "rectangles"
|
|
90
|
+
|
|
77
91
|
def train(self, scale: scale, aesthetic=None):
|
|
78
92
|
self.nbin = cast("int", self.nbin)
|
|
79
93
|
self.title = cast("str", self.title)
|
|
@@ -121,12 +135,6 @@ class guide_colorbar(guide):
|
|
|
121
135
|
self.hash = hashlib.sha256(info.encode("utf-8")).hexdigest()
|
|
122
136
|
return self
|
|
123
137
|
|
|
124
|
-
def merge(self, other):
|
|
125
|
-
"""
|
|
126
|
-
Simply discards the other guide
|
|
127
|
-
"""
|
|
128
|
-
return self
|
|
129
|
-
|
|
130
138
|
def create_geoms(self):
|
|
131
139
|
"""
|
|
132
140
|
Return self if colorbar will be drawn and None if not
|
|
@@ -177,6 +185,7 @@ class guide_colorbar(guide):
|
|
|
177
185
|
nbars = len(self.bar)
|
|
178
186
|
elements = self.elements
|
|
179
187
|
raster = self.display == "raster"
|
|
188
|
+
alpha = self.alpha
|
|
180
189
|
|
|
181
190
|
colors = self.bar["color"].tolist()
|
|
182
191
|
labels = self.key["label"].tolist()
|
|
@@ -225,9 +234,9 @@ class guide_colorbar(guide):
|
|
|
225
234
|
|
|
226
235
|
# colorbar
|
|
227
236
|
if self.display == "rectangles":
|
|
228
|
-
add_segmented_colorbar(auxbox, colors, elements)
|
|
237
|
+
add_segmented_colorbar(auxbox, colors, alpha, elements)
|
|
229
238
|
else:
|
|
230
|
-
add_gradient_colorbar(auxbox, colors, elements, raster)
|
|
239
|
+
add_gradient_colorbar(auxbox, colors, alpha, elements, raster)
|
|
231
240
|
|
|
232
241
|
# ticks
|
|
233
242
|
visible = slice(
|
|
@@ -270,6 +279,7 @@ guide_colourbar = guide_colorbar
|
|
|
270
279
|
def add_gradient_colorbar(
|
|
271
280
|
auxbox: AuxTransformBox,
|
|
272
281
|
colors: Sequence[str],
|
|
282
|
+
alpha: float | None,
|
|
273
283
|
elements: GuideElementsColorbar,
|
|
274
284
|
raster: bool = False,
|
|
275
285
|
):
|
|
@@ -325,6 +335,7 @@ def add_gradient_colorbar(
|
|
|
325
335
|
shading="gouraud",
|
|
326
336
|
cmap=cmap,
|
|
327
337
|
array=Z.ravel(),
|
|
338
|
+
alpha=alpha,
|
|
328
339
|
rasterized=raster,
|
|
329
340
|
)
|
|
330
341
|
auxbox.add_artist(coll)
|
|
@@ -333,6 +344,7 @@ def add_gradient_colorbar(
|
|
|
333
344
|
def add_segmented_colorbar(
|
|
334
345
|
auxbox: AuxTransformBox,
|
|
335
346
|
colors: Sequence[str],
|
|
347
|
+
alpha: float | None,
|
|
336
348
|
elements: GuideElementsColorbar,
|
|
337
349
|
):
|
|
338
350
|
"""
|
|
@@ -341,6 +353,21 @@ def add_segmented_colorbar(
|
|
|
341
353
|
from matplotlib.collections import PolyCollection
|
|
342
354
|
|
|
343
355
|
nbreak = len(colors)
|
|
356
|
+
# Problem:
|
|
357
|
+
# 1. Webbrowsers do not properly render SVG with QuadMesh
|
|
358
|
+
# colorbars. Also when the QuadMesh is "rasterized",
|
|
359
|
+
# the colorbar is misplaced within the SVG (and pdfs!).
|
|
360
|
+
# So SVGs cannot use `add_gradient_colobar` at all.
|
|
361
|
+
# 2. Webbrowsers do not properly render SVG with PolyCollection
|
|
362
|
+
# colorbars when the adjacent rectangles that make up the
|
|
363
|
+
# colorbar touch each other precisely. The "bars" appear to
|
|
364
|
+
# be separated by lines.
|
|
365
|
+
#
|
|
366
|
+
# For a wayout, we overlap the bars. Overlapping creates artefacts
|
|
367
|
+
# when alpha < 1, but having a gradient + alpha is rare. And, we can
|
|
368
|
+
# minimise apparent artefacts by using a large overlap_factor.
|
|
369
|
+
# A value of 2 gives the best results in the rare case should alpha < 1.
|
|
370
|
+
overlap_factor = 2
|
|
344
371
|
if elements.is_vertical:
|
|
345
372
|
colorbar_height = elements.key_height
|
|
346
373
|
colorbar_width = elements.key_width
|
|
@@ -351,6 +378,8 @@ def add_segmented_colorbar(
|
|
|
351
378
|
for i in range(nbreak):
|
|
352
379
|
y1 = i * linewidth
|
|
353
380
|
y2 = y1 + linewidth
|
|
381
|
+
if i > 1:
|
|
382
|
+
y1 -= linewidth * overlap_factor
|
|
354
383
|
verts.append(((x1, y1), (x1, y2), (x2, y2), (x2, y1)))
|
|
355
384
|
else:
|
|
356
385
|
colorbar_width = elements.key_height
|
|
@@ -362,12 +391,15 @@ def add_segmented_colorbar(
|
|
|
362
391
|
for i in range(nbreak):
|
|
363
392
|
x1 = i * linewidth
|
|
364
393
|
x2 = x1 + linewidth
|
|
394
|
+
if i > 1:
|
|
395
|
+
x1 -= linewidth * overlap_factor
|
|
365
396
|
verts.append(((x1, y1), (x1, y2), (x2, y2), (x2, y1)))
|
|
366
397
|
|
|
367
398
|
coll = PolyCollection(
|
|
368
399
|
verts,
|
|
369
400
|
facecolors=colors,
|
|
370
401
|
linewidth=0,
|
|
402
|
+
alpha=alpha,
|
|
371
403
|
antialiased=False,
|
|
372
404
|
)
|
|
373
405
|
auxbox.add_artist(coll)
|
|
@@ -417,28 +449,29 @@ def add_labels(
|
|
|
417
449
|
"""
|
|
418
450
|
from matplotlib.text import Text
|
|
419
451
|
|
|
420
|
-
|
|
421
|
-
sep = elements.text.margin
|
|
452
|
+
seps = elements.text.margins
|
|
422
453
|
texts: list[Text] = []
|
|
423
|
-
|
|
454
|
+
has = elements.text.has
|
|
455
|
+
vas = elements.text.vas
|
|
456
|
+
width = elements.key_width
|
|
424
457
|
|
|
425
458
|
# The horizontal and vertical alignments are set in the theme
|
|
426
459
|
# or dynamically calculates in GuideElements and added to the
|
|
427
460
|
# themeable properties dict
|
|
428
461
|
if elements.is_vertical:
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
462
|
+
xs = [
|
|
463
|
+
width + sep if side == "right" else -sep
|
|
464
|
+
for side, sep in zip(elements.text_positions, seps)
|
|
465
|
+
]
|
|
433
466
|
else:
|
|
434
467
|
xs = ys
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
468
|
+
ys = [
|
|
469
|
+
-sep if side == "bottom" else width + sep
|
|
470
|
+
for side, sep in zip(elements.text_positions, seps)
|
|
471
|
+
]
|
|
439
472
|
|
|
440
|
-
for x, y, s in zip(xs, ys, labels):
|
|
441
|
-
t = Text(x, y, s,
|
|
473
|
+
for x, y, s, ha, va in zip(xs, ys, labels, has, vas):
|
|
474
|
+
t = Text(x, y, s, ha=ha, va=va)
|
|
442
475
|
auxbox.add_artist(t)
|
|
443
476
|
texts.append(t)
|
|
444
477
|
|
|
@@ -474,43 +507,52 @@ class GuideElementsColorbar(GuideElements):
|
|
|
474
507
|
ha = self.theme.getp(("legend_text_colorbar", "ha"))
|
|
475
508
|
va = self.theme.getp(("legend_text_colorbar", "va"))
|
|
476
509
|
is_blank = self.theme.T.is_blank("legend_text_colorbar")
|
|
510
|
+
n = self.guide.num_breaks
|
|
477
511
|
|
|
478
512
|
# Default text alignment depends on the direction of the
|
|
479
513
|
# colorbar
|
|
480
|
-
|
|
514
|
+
centers = ("center",) * n
|
|
515
|
+
has = (ha,) * n if isinstance(ha, str) else ha
|
|
516
|
+
vas = (va,) * n if isinstance(va, str) else va
|
|
517
|
+
opposite_sides = [get_opposite_side(s) for s in self.text_positions]
|
|
481
518
|
if self.is_vertical:
|
|
482
|
-
|
|
483
|
-
|
|
519
|
+
has = has or opposite_sides
|
|
520
|
+
vas = vas or centers
|
|
484
521
|
else:
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
align=None,
|
|
522
|
+
vas = vas or opposite_sides
|
|
523
|
+
has = has or centers
|
|
524
|
+
return guide_text(
|
|
525
|
+
self._text_margin,
|
|
526
|
+
aligns=centers,
|
|
491
527
|
fontsize=size,
|
|
492
|
-
|
|
493
|
-
|
|
528
|
+
has=has, # pyright: ignore[reportArgumentType]
|
|
529
|
+
vas=vas, # pyright: ignore[reportArgumentType]
|
|
494
530
|
is_blank=is_blank,
|
|
495
531
|
)
|
|
496
532
|
|
|
497
533
|
@cached_property
|
|
498
|
-
def
|
|
499
|
-
if not (
|
|
534
|
+
def text_positions(self) -> Sequence[Side]:
|
|
535
|
+
if not (user_position := self.theme.getp("legend_text_position")):
|
|
500
536
|
position = "right" if self.is_vertical else "bottom"
|
|
537
|
+
return (position,) * self.guide.num_breaks
|
|
501
538
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
539
|
+
alternate = {"left-right", "right-left", "bottom-top", "top-bottom"}
|
|
540
|
+
if user_position in alternate:
|
|
541
|
+
tup = user_position.split("-")
|
|
542
|
+
return [tup[i % 2] for i in range(self.guide.num_breaks)]
|
|
543
|
+
|
|
544
|
+
position = cast("Side | Sequence[Side]", user_position)
|
|
545
|
+
|
|
546
|
+
if isinstance(position, str):
|
|
547
|
+
position = (position,) * self.guide.num_breaks
|
|
548
|
+
|
|
549
|
+
valid = {"right", "left"} if self.is_vertical else {"bottom", "top"}
|
|
550
|
+
if any(p for p in position if p not in valid):
|
|
551
|
+
raise PlotnineError(
|
|
552
|
+
"The text position for a horizontal legend must be "
|
|
553
|
+
f"either one of {valid!r}. I got {user_position!r}."
|
|
512
554
|
)
|
|
513
|
-
|
|
555
|
+
|
|
514
556
|
return position
|
|
515
557
|
|
|
516
558
|
@cached_property
|
plotnine/guides/guide_legend.py
CHANGED
|
@@ -5,20 +5,21 @@ from contextlib import suppress
|
|
|
5
5
|
from dataclasses import dataclass, field
|
|
6
6
|
from functools import cached_property
|
|
7
7
|
from itertools import islice
|
|
8
|
-
from types import SimpleNamespace as NS
|
|
9
8
|
from typing import TYPE_CHECKING, cast
|
|
10
9
|
from warnings import warn
|
|
11
10
|
|
|
12
11
|
import numpy as np
|
|
13
12
|
import pandas as pd
|
|
14
13
|
|
|
14
|
+
from plotnine.iapi import guide_text
|
|
15
|
+
|
|
15
16
|
from .._utils import remove_missing
|
|
16
17
|
from ..exceptions import PlotnineError, PlotnineWarning
|
|
17
18
|
from ..mapping.aes import rename_aesthetics
|
|
18
19
|
from .guide import GuideElements, guide
|
|
19
20
|
|
|
20
21
|
if TYPE_CHECKING:
|
|
21
|
-
from typing import Any, Optional
|
|
22
|
+
from typing import Any, Optional, Sequence
|
|
22
23
|
|
|
23
24
|
from matplotlib.artist import Artist
|
|
24
25
|
from matplotlib.offsetbox import PackerBase
|
|
@@ -209,7 +210,7 @@ class guide_legend(guide):
|
|
|
209
210
|
self, elements: GuideElementsLegend
|
|
210
211
|
) -> tuple[int, int]:
|
|
211
212
|
nrow, ncol = self.nrow, self.ncol
|
|
212
|
-
nbreak =
|
|
213
|
+
nbreak = self.num_breaks
|
|
213
214
|
|
|
214
215
|
if nrow and ncol:
|
|
215
216
|
if nrow * ncol < nbreak:
|
|
@@ -248,7 +249,7 @@ class guide_legend(guide):
|
|
|
248
249
|
|
|
249
250
|
obverse = slice(0, None)
|
|
250
251
|
reverse = slice(None, None, -1)
|
|
251
|
-
nbreak =
|
|
252
|
+
nbreak = self.num_breaks
|
|
252
253
|
targets = self.theme.targets
|
|
253
254
|
keys_order = reverse if self.reverse else obverse
|
|
254
255
|
elements = self.elements
|
|
@@ -259,8 +260,12 @@ class guide_legend(guide):
|
|
|
259
260
|
targets.legend_title = title_box._text # type: ignore
|
|
260
261
|
|
|
261
262
|
# labels
|
|
262
|
-
|
|
263
|
-
|
|
263
|
+
has = elements.text.has
|
|
264
|
+
vas = elements.text.vas
|
|
265
|
+
labels = [
|
|
266
|
+
TextArea(s, textprops={"ha": ha, "va": va})
|
|
267
|
+
for s, ha, va in zip(self.key["label"], has, vas)
|
|
268
|
+
]
|
|
264
269
|
_texts = [l._text for l in labels] # type: ignore
|
|
265
270
|
targets.legend_text_legend = _texts
|
|
266
271
|
|
|
@@ -287,18 +292,28 @@ class guide_legend(guide):
|
|
|
287
292
|
"bottom": (VPacker, reverse),
|
|
288
293
|
"top": (VPacker, obverse),
|
|
289
294
|
}
|
|
290
|
-
|
|
295
|
+
|
|
291
296
|
if self.elements.text.is_blank:
|
|
292
297
|
key_boxes = [d for d in drawings][keys_order]
|
|
293
298
|
else:
|
|
299
|
+
packers, slices = [], []
|
|
300
|
+
for side in elements.text_positions:
|
|
301
|
+
tup = lookup[side]
|
|
302
|
+
packers.append(tup[0])
|
|
303
|
+
slices.append(tup[1])
|
|
304
|
+
|
|
305
|
+
seps = elements.text.margins
|
|
306
|
+
aligns = elements.text.aligns
|
|
294
307
|
key_boxes = [
|
|
295
308
|
packer(
|
|
296
309
|
children=[l, d][slc],
|
|
297
|
-
sep=
|
|
298
|
-
align=
|
|
310
|
+
sep=sep,
|
|
311
|
+
align=align,
|
|
299
312
|
pad=0,
|
|
300
313
|
)
|
|
301
|
-
for d, l in zip(
|
|
314
|
+
for d, l, packer, slc, sep, align in zip(
|
|
315
|
+
drawings, labels, packers, slices, seps, aligns
|
|
316
|
+
)
|
|
302
317
|
][keys_order]
|
|
303
318
|
|
|
304
319
|
# Put the entries together in rows or columns
|
|
@@ -364,26 +379,38 @@ class GuideElementsLegend(GuideElements):
|
|
|
364
379
|
ha = self.theme.getp(("legend_text_legend", "ha"), "center")
|
|
365
380
|
va = self.theme.getp(("legend_text_legend", "va"), "center")
|
|
366
381
|
is_blank = self.theme.T.is_blank("legend_text_legend")
|
|
382
|
+
n = self.guide.num_breaks
|
|
367
383
|
|
|
368
384
|
# The original ha & va values are used by the HPacker/VPacker
|
|
369
385
|
# to align the TextArea with the DrawingArea.
|
|
370
386
|
# We set ha & va to values that combine best with the aligning
|
|
371
387
|
# for the text area.
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
388
|
+
has = (ha,) * n if isinstance(ha, str) else ha
|
|
389
|
+
vas = (va,) * n if isinstance(va, str) else va
|
|
390
|
+
aligns = [
|
|
391
|
+
va if side in ("right", "left") else ha
|
|
392
|
+
for side, ha, va in zip(self.text_positions, has, vas)
|
|
393
|
+
]
|
|
394
|
+
return guide_text(
|
|
395
|
+
margins=self._text_margin,
|
|
396
|
+
aligns=aligns, # pyright: ignore[reportArgumentType]
|
|
376
397
|
fontsize=size,
|
|
377
|
-
|
|
378
|
-
|
|
398
|
+
has=("center",) * n,
|
|
399
|
+
vas=("baseline",) * n,
|
|
379
400
|
is_blank=is_blank,
|
|
380
401
|
)
|
|
381
402
|
|
|
382
403
|
@cached_property
|
|
383
|
-
def
|
|
384
|
-
if not (
|
|
385
|
-
|
|
386
|
-
|
|
404
|
+
def text_positions(self) -> Sequence[Side]:
|
|
405
|
+
if not (position := self.theme.getp("legend_text_position")):
|
|
406
|
+
return ("right",) * self.guide.num_breaks
|
|
407
|
+
|
|
408
|
+
position = cast("Side | Sequence[Side]", position)
|
|
409
|
+
|
|
410
|
+
if isinstance(position, str):
|
|
411
|
+
position = (position,) * self.guide.num_breaks
|
|
412
|
+
|
|
413
|
+
return position
|
|
387
414
|
|
|
388
415
|
@cached_property
|
|
389
416
|
def key_spacing_x(self) -> float:
|
plotnine/guides/guides.py
CHANGED
|
@@ -31,12 +31,12 @@ if TYPE_CHECKING:
|
|
|
31
31
|
from plotnine.scales.scale import scale
|
|
32
32
|
from plotnine.scales.scales import Scales
|
|
33
33
|
from plotnine.typing import (
|
|
34
|
+
Justification,
|
|
34
35
|
LegendPosition,
|
|
35
36
|
NoGuide,
|
|
36
37
|
Orientation,
|
|
37
38
|
ScaledAestheticsName,
|
|
38
39
|
Side,
|
|
39
|
-
TextJustification,
|
|
40
40
|
)
|
|
41
41
|
|
|
42
42
|
LegendOrColorbar: TypeAlias = (
|
|
@@ -437,7 +437,7 @@ class GuidesElements:
|
|
|
437
437
|
return ensure_xy_location(just)
|
|
438
438
|
|
|
439
439
|
@cached_property
|
|
440
|
-
def box_just(self) ->
|
|
440
|
+
def box_just(self) -> Justification | Literal["baseline"]:
|
|
441
441
|
if not (box_just := self.theme.getp("legend_box_just")):
|
|
442
442
|
box_just = (
|
|
443
443
|
"left" if self.position in {"left", "right"} else "right"
|