plotnine 0.15.0.dev3__py3-none-any.whl → 0.15.2__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 (139) hide show
  1. plotnine/__init__.py +2 -0
  2. plotnine/_mpl/layout_manager/_engine.py +1 -1
  3. plotnine/_mpl/layout_manager/_layout_items.py +126 -41
  4. plotnine/_mpl/layout_manager/_layout_tree.py +712 -314
  5. plotnine/_mpl/layout_manager/_spaces.py +305 -101
  6. plotnine/_mpl/patches.py +70 -34
  7. plotnine/_mpl/text.py +144 -63
  8. plotnine/_mpl/utils.py +1 -1
  9. plotnine/_utils/__init__.py +50 -107
  10. plotnine/_utils/context.py +78 -2
  11. plotnine/_utils/ipython.py +35 -51
  12. plotnine/_utils/quarto.py +26 -0
  13. plotnine/_utils/yippie.py +115 -0
  14. plotnine/composition/__init__.py +11 -0
  15. plotnine/composition/_beside.py +55 -0
  16. plotnine/composition/_compose.py +471 -0
  17. plotnine/composition/_plot_spacer.py +60 -0
  18. plotnine/composition/_stack.py +55 -0
  19. plotnine/coords/coord.py +3 -3
  20. plotnine/data/__init__.py +31 -0
  21. plotnine/data/anscombe-quartet.csv +45 -0
  22. plotnine/doctools.py +4 -4
  23. plotnine/facets/facet.py +4 -4
  24. plotnine/facets/strips.py +17 -28
  25. plotnine/geoms/annotate.py +13 -13
  26. plotnine/geoms/annotation_logticks.py +7 -8
  27. plotnine/geoms/annotation_stripes.py +6 -6
  28. plotnine/geoms/geom.py +60 -27
  29. plotnine/geoms/geom_abline.py +3 -2
  30. plotnine/geoms/geom_area.py +2 -2
  31. plotnine/geoms/geom_bar.py +1 -0
  32. plotnine/geoms/geom_bin_2d.py +6 -2
  33. plotnine/geoms/geom_blank.py +0 -3
  34. plotnine/geoms/geom_boxplot.py +8 -4
  35. plotnine/geoms/geom_col.py +2 -2
  36. plotnine/geoms/geom_count.py +6 -2
  37. plotnine/geoms/geom_crossbar.py +3 -3
  38. plotnine/geoms/geom_density_2d.py +6 -2
  39. plotnine/geoms/geom_dotplot.py +2 -2
  40. plotnine/geoms/geom_errorbar.py +2 -2
  41. plotnine/geoms/geom_errorbarh.py +2 -2
  42. plotnine/geoms/geom_histogram.py +1 -1
  43. plotnine/geoms/geom_hline.py +3 -2
  44. plotnine/geoms/geom_linerange.py +2 -2
  45. plotnine/geoms/geom_map.py +5 -5
  46. plotnine/geoms/geom_path.py +11 -12
  47. plotnine/geoms/geom_point.py +4 -5
  48. plotnine/geoms/geom_pointdensity.py +4 -0
  49. plotnine/geoms/geom_pointrange.py +3 -5
  50. plotnine/geoms/geom_polygon.py +2 -3
  51. plotnine/geoms/geom_qq.py +4 -0
  52. plotnine/geoms/geom_qq_line.py +4 -0
  53. plotnine/geoms/geom_quantile.py +4 -0
  54. plotnine/geoms/geom_raster.py +4 -5
  55. plotnine/geoms/geom_rect.py +3 -4
  56. plotnine/geoms/geom_ribbon.py +7 -7
  57. plotnine/geoms/geom_rug.py +1 -1
  58. plotnine/geoms/geom_segment.py +2 -2
  59. plotnine/geoms/geom_sina.py +3 -3
  60. plotnine/geoms/geom_smooth.py +7 -3
  61. plotnine/geoms/geom_step.py +2 -2
  62. plotnine/geoms/geom_text.py +2 -3
  63. plotnine/geoms/geom_violin.py +8 -5
  64. plotnine/geoms/geom_vline.py +3 -2
  65. plotnine/ggplot.py +64 -85
  66. plotnine/guides/guide.py +7 -10
  67. plotnine/guides/guide_colorbar.py +3 -3
  68. plotnine/guides/guide_legend.py +3 -3
  69. plotnine/guides/guides.py +6 -6
  70. plotnine/helpers.py +49 -0
  71. plotnine/iapi.py +28 -5
  72. plotnine/labels.py +3 -3
  73. plotnine/layer.py +36 -19
  74. plotnine/mapping/_atomic.py +178 -0
  75. plotnine/mapping/_env.py +13 -2
  76. plotnine/mapping/_eval_environment.py +1 -1
  77. plotnine/mapping/aes.py +85 -49
  78. plotnine/scales/__init__.py +2 -0
  79. plotnine/scales/limits.py +7 -7
  80. plotnine/scales/scale.py +3 -3
  81. plotnine/scales/scale_color.py +82 -18
  82. plotnine/scales/scale_continuous.py +6 -4
  83. plotnine/scales/scale_datetime.py +28 -14
  84. plotnine/scales/scale_discrete.py +1 -1
  85. plotnine/scales/scale_identity.py +21 -2
  86. plotnine/scales/scale_manual.py +8 -2
  87. plotnine/scales/scale_xy.py +2 -2
  88. plotnine/stats/binning.py +4 -1
  89. plotnine/stats/smoothers.py +23 -36
  90. plotnine/stats/stat.py +20 -32
  91. plotnine/stats/stat_bin.py +6 -5
  92. plotnine/stats/stat_bin_2d.py +11 -9
  93. plotnine/stats/stat_bindot.py +13 -16
  94. plotnine/stats/stat_boxplot.py +6 -6
  95. plotnine/stats/stat_count.py +6 -9
  96. plotnine/stats/stat_density.py +7 -10
  97. plotnine/stats/stat_density_2d.py +12 -8
  98. plotnine/stats/stat_ecdf.py +7 -6
  99. plotnine/stats/stat_ellipse.py +9 -6
  100. plotnine/stats/stat_function.py +10 -8
  101. plotnine/stats/stat_hull.py +6 -3
  102. plotnine/stats/stat_identity.py +5 -2
  103. plotnine/stats/stat_pointdensity.py +5 -7
  104. plotnine/stats/stat_qq.py +46 -20
  105. plotnine/stats/stat_qq_line.py +16 -11
  106. plotnine/stats/stat_quantile.py +15 -9
  107. plotnine/stats/stat_sina.py +13 -15
  108. plotnine/stats/stat_smooth.py +8 -10
  109. plotnine/stats/stat_sum.py +5 -2
  110. plotnine/stats/stat_summary.py +7 -10
  111. plotnine/stats/stat_summary_bin.py +11 -14
  112. plotnine/stats/stat_unique.py +5 -2
  113. plotnine/stats/stat_ydensity.py +8 -11
  114. plotnine/themes/elements/__init__.py +2 -1
  115. plotnine/themes/elements/element_line.py +17 -9
  116. plotnine/themes/elements/margin.py +64 -1
  117. plotnine/themes/theme.py +9 -1
  118. plotnine/themes/theme_538.py +0 -1
  119. plotnine/themes/theme_bw.py +0 -1
  120. plotnine/themes/theme_dark.py +0 -1
  121. plotnine/themes/theme_gray.py +6 -5
  122. plotnine/themes/theme_light.py +1 -1
  123. plotnine/themes/theme_matplotlib.py +5 -5
  124. plotnine/themes/theme_seaborn.py +7 -4
  125. plotnine/themes/theme_void.py +9 -8
  126. plotnine/themes/theme_xkcd.py +0 -1
  127. plotnine/themes/themeable.py +109 -31
  128. plotnine/typing.py +17 -6
  129. plotnine/watermark.py +3 -3
  130. {plotnine-0.15.0.dev3.dist-info → plotnine-0.15.2.dist-info}/METADATA +13 -6
  131. plotnine-0.15.2.dist-info/RECORD +221 -0
  132. {plotnine-0.15.0.dev3.dist-info → plotnine-0.15.2.dist-info}/WHEEL +1 -1
  133. plotnine/plot_composition/__init__.py +0 -10
  134. plotnine/plot_composition/_compose.py +0 -436
  135. plotnine/plot_composition/_spacer.py +0 -32
  136. plotnine-0.15.0.dev3.dist-info/RECORD +0 -215
  137. /plotnine/{plot_composition → composition}/_plotspec.py +0 -0
  138. {plotnine-0.15.0.dev3.dist-info → plotnine-0.15.2.dist-info}/licenses/LICENSE +0 -0
  139. {plotnine-0.15.0.dev3.dist-info → plotnine-0.15.2.dist-info}/top_level.txt +0 -0
plotnine/_mpl/patches.py CHANGED
@@ -4,12 +4,11 @@ from typing import TYPE_CHECKING
4
4
 
5
5
  from matplotlib import artist
6
6
  from matplotlib.patches import FancyBboxPatch, Rectangle
7
- from matplotlib.text import _get_textbox # type: ignore
8
- from matplotlib.transforms import Affine2D
7
+ from matplotlib.transforms import Bbox
9
8
 
10
- if TYPE_CHECKING:
11
- from matplotlib.backend_bases import RendererBase
9
+ from plotnine._mpl.utils import rel_position
12
10
 
11
+ if TYPE_CHECKING:
13
12
  from plotnine.typing import StripPosition
14
13
 
15
14
  from .text import StripText
@@ -26,51 +25,88 @@ class StripTextPatch(FancyBboxPatch):
26
25
  Strip text background box
27
26
  """
28
27
 
29
- # The text artists that is wrapped by this box
30
28
  text: StripText
29
+ """
30
+ The text artists that is wrapped by this box
31
+ """
32
+
31
33
  position: StripPosition
32
- _update = False
34
+ """
35
+ The position of the strip_text associated with this patch
36
+ """
33
37
 
34
- def __init__(self, text: StripText):
35
- boxstyle = f"square, pad={text.draw_info.strip_text_margin}"
38
+ expand: float = 1
39
+ """
40
+ Factor by which to expand the thickness of this patch.
36
41
 
42
+ This value is used by the layout manager to increase the breadth
43
+ of the narrower strip_backgrounds.
44
+ """
45
+
46
+ def __init__(self, text: StripText):
37
47
  super().__init__(
38
- (0, 0), 1, 1, boxstyle=boxstyle, clip_on=False, zorder=2.2
48
+ # The position, width and height are determine in
49
+ # .get_window_extent.
50
+ (0, 0),
51
+ width=1,
52
+ height=1,
53
+ boxstyle="square, pad=0",
54
+ clip_on=False,
55
+ zorder=2.2,
39
56
  )
40
57
 
41
58
  self.text = text
42
59
  self.position = text.draw_info.position
43
60
 
44
- def update_position_size(self, renderer: RendererBase):
61
+ def get_window_extent(self, renderer=None):
45
62
  """
46
- Update the location and the size of the bbox.
63
+ Location & dimensions of the box in display coordinates
47
64
  """
48
- if self._update:
49
- return
50
-
51
- text = self.text
52
- posx, posy = text.get_transform().transform(text.get_position())
53
- x, y, w, h = _get_textbox(text, renderer)
54
-
55
- self.set_bounds(0.0, 0.0, w, h)
56
- self.set_transform(
57
- Affine2D()
58
- .rotate_deg(text.get_rotation())
59
- .translate(posx + x, posy + y)
60
- )
61
- fontsize_in_pixel = renderer.points_to_pixels(
62
- text.get_size() # type: ignore
63
- )
64
- self.set_mutation_scale(fontsize_in_pixel) # type: ignore
65
- self._update = True
65
+ info = self.text.draw_info
66
+ m = info.margin
67
+
68
+ # bboxes in display space
69
+ text_bbox = self.text.get_window_extent(renderer)
70
+ ax_bbox = info.ax.bbox.frozen()
71
+
72
+ # line height in display space
73
+ line_height = self.text._line_height(renderer)
74
+
75
+ # Convert the bottom left coordinates of the patch from
76
+ # transAxes to display space. We are not justifying the patch
77
+ # within the axes so we use 0 for the lengths, this gives us
78
+ # a patch that starts at the edge of the axes and not one that
79
+ # ends at the edge
80
+ x0 = rel_position(info.bg_x, 0, ax_bbox.x0, ax_bbox.x1)
81
+ y0 = rel_position(info.bg_y, 0, ax_bbox.y0, ax_bbox.y1)
82
+
83
+ # info.bg_width and info.bg_height are in axes space
84
+ # so they are a scaling factor
85
+ if info.position == "top":
86
+ width = ax_bbox.width * info.bg_width
87
+ height = text_bbox.height + ((m.b + m.t) * line_height)
88
+ height *= self.expand
89
+ y0 += height * info.strip_align
90
+ else:
91
+ height = ax_bbox.height * info.bg_height
92
+ width = text_bbox.width + ((m.l + m.r) * line_height)
93
+ width *= self.expand
94
+ x0 += width * info.strip_align
95
+
96
+ return Bbox.from_bounds(x0, y0, width, height)
66
97
 
67
- def get_window_extent(self, renderer=None):
98
+ @artist.allow_rasterization
99
+ def draw(self, renderer):
68
100
  """
69
- Location & dimensions of the box
101
+ Draw patch
70
102
  """
71
- if renderer:
72
- self.update_position_size(renderer)
73
- return super().get_window_extent(renderer)
103
+ # The geometry of the patch is determined by its rectangular bounds,
104
+ # this is also its "window_extent". As the extent value is in
105
+ # display units, we don't need a transform.
106
+ bbox = self.get_window_extent(renderer)
107
+ self.set_bounds(bbox.bounds)
108
+ self.set_transform(None)
109
+ return super().draw(renderer)
74
110
 
75
111
 
76
112
  class InsideStrokedRectangle(Rectangle):
plotnine/_mpl/text.py CHANGED
@@ -1,9 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from functools import lru_cache
3
4
  from typing import TYPE_CHECKING
4
5
 
6
+ from matplotlib import artist
5
7
  from matplotlib.text import Text
6
8
 
9
+ from plotnine._utils import ha_as_float, va_as_float
10
+
7
11
  from .patches import StripTextPatch
8
12
  from .utils import bbox_in_axes_space, rel_position
9
13
 
@@ -11,7 +15,6 @@ if TYPE_CHECKING:
11
15
  from matplotlib.backend_bases import RendererBase
12
16
 
13
17
  from plotnine.iapi import strip_draw_info
14
- from plotnine.typing import HorizontalJustification, VerticalJustification
15
18
 
16
19
 
17
20
  class StripText(Text):
@@ -28,86 +31,164 @@ class StripText(Text):
28
31
  "transform": info.ax.transAxes,
29
32
  "clip_on": False,
30
33
  "zorder": 3.3,
34
+ # Since the text can be rotated, it is simpler to anchor it at
35
+ # the center, align it, then do the rotation. Vertically,
36
+ # center_baseline places the text in the visual center, but
37
+ # only if it is one line. For multiline text, we are better
38
+ # off with plain center.
39
+ "ha": "center",
40
+ "va": "center_baseline" if info.is_oneline else "center",
41
+ "rotation_mode": "anchor",
31
42
  }
32
43
 
33
- super().__init__(
34
- info.x,
35
- info.y,
36
- info.label,
37
- **kwargs,
38
- )
44
+ super().__init__(0, 0, info.label, **kwargs)
39
45
  self.draw_info = info
40
46
  self.patch = StripTextPatch(self)
41
47
 
42
- # TODO: Move these _justify methods to the layout manager
43
- # We need to first make sure that the patch has the final size during
44
- # layout computation. Right now, the final size is calculated during
45
- # draw (in these justify methods)
46
- def _justify_horizontally(self, renderer):
48
+ # TODO: This should really be part of the unit conversions in the
49
+ # margin class.
50
+ @lru_cache(2)
51
+ def _line_height(self, renderer) -> float:
52
+ """
53
+ The line height in display space of the text on the canvas
54
+ """
55
+ # Text string, (width, height), x, y
56
+ parts: list[tuple[str, tuple[float, float], float, float]]
57
+
58
+ try:
59
+ # matplotlib.Text._get_layout is a private API and we cannot
60
+ # tell how using it may fail in the future.
61
+ _, parts, _ = self._get_layout(renderer) # pyright: ignore[reportAttributeAccessIssue]
62
+ except Exception:
63
+ from warnings import warn
64
+
65
+ from plotnine.exceptions import PlotnineWarning
66
+
67
+ # The canvas height is nearly always bigger than the stated
68
+ # fontsize. 1.36 is a good multiplication factor obtained by
69
+ # some rough exploration
70
+ f = 1.36
71
+ size = self.get_fontsize()
72
+ height = round(size * f) if isinstance(size, int) else 14
73
+ warn(
74
+ f"Could not calculate line height for {self.get_text()}. "
75
+ "Using an estimate, please let us know about this at "
76
+ "https://github.com/has2k1/plotnine/issues",
77
+ PlotnineWarning,
78
+ )
79
+ else:
80
+ # The the text has multiple lines, we use the maximum height
81
+ # of anyone single line.
82
+ height = max([p[1][1] for p in parts])
83
+
84
+ return height
85
+
86
+ def _set_position(self, renderer):
87
+ """
88
+ Set the postion of the text within the strip_background
89
+ """
90
+ # We have to two premises that depend on each other:
91
+ #
92
+ # 1. The breadth of the strip_background grows to accomodate
93
+ # the strip_text.
94
+ # 2. The strip_text is justified within the strip_background.
95
+ #
96
+ # From these we note that the strip_background does not need the
97
+ # position of the strip_text, but it needs its size. Therefore
98
+ # we implement StripTextPatch.get_window_extent can use
99
+ # StripText.get_window_extent, peeking only at the size.
100
+ #
101
+ # And we implement StripText._set_position_* to use
102
+ # StripTextPatch.get_window_extent and make the calculations in
103
+ # both methods independent.
104
+ if self.draw_info.position == "top":
105
+ self._set_position_top(renderer)
106
+ else: # "right"
107
+ self._set_position_right(renderer)
108
+
109
+ def _set_position_top(self, renderer):
47
110
  """
48
- Justify the text along the strip_background
111
+ Set position of the text within the top strip_background
49
112
  """
50
113
  info = self.draw_info
51
- lookup: dict[HorizontalJustification, float] = {
52
- "left": 0.0,
53
- "center": 0.5,
54
- "right": 1.0,
55
- }
56
- rel = lookup.get(info.ha, 0.5) if isinstance(info.ha, str) else info.ha
57
- patch_bbox = bbox_in_axes_space(self.patch, info.ax, renderer)
58
- text_bbox = bbox_in_axes_space(self, info.ax, renderer)
59
- l, b, w, h = info.x, info.y, info.box_width, patch_bbox.height
60
- b = b + patch_bbox.height * info.strip_align
61
- x = rel_position(rel, text_bbox.width, patch_bbox.x0, patch_bbox.x1)
62
- y = b + h / 2
63
- self.set_horizontalalignment("left")
64
- self.patch.set_bounds(l, b, w, h)
114
+ ha, va, ax, m = info.ha, info.va, info.ax, info.margin
115
+
116
+ rel_x, rel_y = ha_as_float(ha), va_as_float(va)
117
+ patch_bbox = bbox_in_axes_space(self.patch, ax, renderer)
118
+ text_bbox = bbox_in_axes_space(self, ax, renderer)
119
+
120
+ # line_height and margins in axes space
121
+ line_height = self._line_height(renderer) / ax.bbox.height
122
+
123
+ x = (
124
+ # Justify horizontally within the strip_background
125
+ rel_position(
126
+ rel_x,
127
+ text_bbox.width + (line_height * (m.l + m.r)),
128
+ patch_bbox.x0,
129
+ patch_bbox.x1,
130
+ )
131
+ + (m.l * line_height)
132
+ + text_bbox.width / 2
133
+ )
134
+ # Setting the y position based on the bounding box is wrong
135
+ y = (
136
+ rel_position(
137
+ rel_y,
138
+ text_bbox.height,
139
+ patch_bbox.y0 + m.b * line_height,
140
+ patch_bbox.y1 - m.t * line_height,
141
+ )
142
+ + text_bbox.height / 2
143
+ )
65
144
  self.set_position((x, y))
66
145
 
67
- def _justify_vertically(self, renderer):
146
+ def _set_position_right(self, renderer):
68
147
  """
69
- Justify the text along the strip_background
148
+ Set position of the text within the bottom strip_background
70
149
  """
71
- # Note that the strip text & background and horizontal but
72
- # rotated to appear vertical. So we really are still justifying
73
- # horizontally.
74
150
  info = self.draw_info
75
- lookup: dict[VerticalJustification, float] = {
76
- "bottom": 0.0,
77
- "center": 0.5,
78
- "top": 1.0,
79
- }
80
- rel = lookup.get(info.va, 0.5) if isinstance(info.va, str) else info.va
81
- patch_bbox = bbox_in_axes_space(self.patch, info.ax, renderer)
82
- text_bbox = bbox_in_axes_space(self, info.ax, renderer)
83
- l, b, w, h = info.x, info.y, patch_bbox.width, info.box_height
84
- l = l + patch_bbox.width * info.strip_align
85
- x = l + w / 2
86
- y = rel_position(rel, text_bbox.height, patch_bbox.y0, patch_bbox.y1)
87
- self.set_horizontalalignment("right") # 90CW right means bottom
88
- self.patch.set_bounds(l, b, w, h)
89
- self.set_position((x, y))
151
+ ha, va, ax, m = info.ha, info.va, info.ax, info.margin
90
152
 
91
- def draw(self, renderer: RendererBase):
92
- if not self.get_visible():
93
- return
153
+ # bboxes in axes space
154
+ patch_bbox = bbox_in_axes_space(self.patch, ax, renderer)
155
+ text_bbox = bbox_in_axes_space(self, ax, renderer)
94
156
 
95
- # expand strip_text patch to contain the text
96
- self.patch.update_position_size(renderer)
157
+ # line_height in axes space
158
+ line_height = self._line_height(renderer) / ax.bbox.width
97
159
 
98
- # Align patch across the edge of the panel
99
- if self.draw_info.position == "top":
100
- self._justify_horizontally(renderer)
101
- else: # "right"
102
- self._justify_vertically(renderer)
160
+ rel_x, rel_y = ha_as_float(ha), va_as_float(va)
103
161
 
104
- self.patch.set_transform(self.draw_info.ax.transAxes)
105
- self.patch.set_mutation_scale(0)
162
+ x = (
163
+ rel_position(
164
+ rel_x,
165
+ text_bbox.width,
166
+ patch_bbox.x0 + m.l * line_height,
167
+ patch_bbox.x1 - m.r * line_height,
168
+ )
169
+ + text_bbox.width / 2
170
+ )
171
+ y = (
172
+ # Justify vertically within the strip_background
173
+ rel_position(
174
+ rel_y,
175
+ text_bbox.height + ((m.b + m.t) * line_height),
176
+ patch_bbox.y0,
177
+ patch_bbox.y1,
178
+ )
179
+ + (m.b * line_height)
180
+ + text_bbox.height / 2
181
+ )
182
+ self.set_position((x, y))
106
183
 
107
- # Put text in center of patch
108
- self.set_rotation_mode("anchor")
109
- self.set_verticalalignment("center_baseline")
184
+ @artist.allow_rasterization
185
+ def draw(self, renderer: RendererBase):
186
+ """
187
+ Draw text along with the patch
188
+ """
189
+ if not self.get_visible():
190
+ return
110
191
 
111
- # Draw spatch
192
+ self._set_position(renderer)
112
193
  self.patch.draw(renderer)
113
194
  return super().draw(renderer)
plotnine/_mpl/utils.py CHANGED
@@ -139,7 +139,7 @@ def draw_bbox(bbox, figure, color="black", **kwargs):
139
139
  width=bbox.width,
140
140
  height=bbox.height,
141
141
  edgecolor=color,
142
- fill=False,
142
+ fill="facecolor" in kwargs,
143
143
  clip_on=False,
144
144
  **kwargs,
145
145
  )
@@ -12,9 +12,10 @@ from collections.abc import Iterable, Sequence
12
12
  from contextlib import suppress
13
13
  from copy import deepcopy
14
14
  from dataclasses import field
15
- from typing import TYPE_CHECKING, cast, overload
15
+ from typing import TYPE_CHECKING
16
16
  from warnings import warn
17
17
 
18
+ import mizani._colors.utils as color_utils
18
19
  import numpy as np
19
20
  import pandas as pd
20
21
  from pandas.core.groupby import DataFrameGroupBy
@@ -26,17 +27,17 @@ if TYPE_CHECKING:
26
27
  from typing import Any, Callable, Literal, TypeVar
27
28
 
28
29
  import numpy.typing as npt
29
- from matplotlib.typing import ColorType
30
30
  from typing_extensions import TypeGuard
31
31
 
32
32
  from plotnine.typing import (
33
33
  AnyArrayLike,
34
- AnySeries,
35
34
  DataLike,
36
35
  FloatArray,
37
36
  FloatArrayLike,
37
+ HorizontalJustification,
38
38
  IntArray,
39
- SidePosition,
39
+ Side,
40
+ VerticalJustification,
40
41
  )
41
42
 
42
43
  T = TypeVar("T")
@@ -58,6 +59,8 @@ BOX_LOCATIONS: dict[str, tuple[float, float]] = {
58
59
  "centre": (0.5, 0.5),
59
60
  }
60
61
 
62
+ to_rgba = color_utils.to_rgba
63
+
61
64
 
62
65
  def is_scalar(val):
63
66
  """
@@ -344,6 +347,8 @@ def _id_var(x: AnyArrayLike, drop: bool = False) -> list[int]:
344
347
  # NaNs are -1, we give them the highest code
345
348
  nan_code = -1
346
349
  new_nan_code = np.max(x.cat.codes) + 1
350
+ # TODO: We are assuming that x is of type Sequence[int|nan]
351
+ # is that accurate.
347
352
  lst = [val if val != nan_code else new_nan_code for val in x]
348
353
  else:
349
354
  lst = list(x.cat.codes + 1)
@@ -511,7 +516,7 @@ def remove_missing(
511
516
  if finite:
512
517
  lst = [np.inf, -np.inf]
513
518
  to_replace = {v: lst for v in vars}
514
- data.replace(to_replace, np.nan, inplace=True) # pyright: ignore[reportArgumentType,reportCallIssue]
519
+ data.replace(to_replace, np.nan, inplace=True)
515
520
  txt = "non-finite"
516
521
  else:
517
522
  txt = "missing"
@@ -526,105 +531,6 @@ def remove_missing(
526
531
  return data
527
532
 
528
533
 
529
- @overload
530
- def to_rgba(colors: ColorType, alpha: float) -> ColorType: ...
531
-
532
-
533
- @overload
534
- def to_rgba(
535
- colors: Sequence[ColorType], alpha: float
536
- ) -> Sequence[ColorType] | ColorType: ...
537
-
538
-
539
- @overload
540
- def to_rgba(
541
- colors: AnySeries, alpha: AnySeries
542
- ) -> Sequence[ColorType] | ColorType: ...
543
-
544
-
545
- def to_rgba(
546
- colors: Sequence[ColorType] | AnySeries | ColorType,
547
- alpha: float | Sequence[float] | AnySeries,
548
- ) -> Sequence[ColorType] | ColorType:
549
- """
550
- Convert hex colors to rgba values.
551
-
552
- Parameters
553
- ----------
554
- colors : iterable | str
555
- colors to convert
556
- alphas : iterable | float
557
- alpha values
558
-
559
- Returns
560
- -------
561
- out : ndarray | tuple
562
- rgba color(s)
563
-
564
- Notes
565
- -----
566
- Matplotlib plotting functions only accept scalar
567
- alpha values. Hence no two objects with different
568
- alpha values may be plotted in one call. This would
569
- make plots with continuous alpha values innefficient.
570
- However :), the colors can be rgba hex values or
571
- list-likes and the alpha dimension will be respected.
572
- """
573
-
574
- def is_iterable(var):
575
- return np.iterable(var) and not isinstance(var, str)
576
-
577
- def has_alpha(c):
578
- return (isinstance(c, tuple) and len(c) == 4) or (
579
- isinstance(c, str) and len(c) == 9 and c[0] == "#"
580
- )
581
-
582
- def no_color(c):
583
- return c is None or c.lower() in ("none", "")
584
-
585
- def to_rgba_hex(c: ColorType, a: float) -> str:
586
- """
587
- Convert rgb color to rgba hex value
588
-
589
- If color c has an alpha channel, then alpha value
590
- a is ignored
591
- """
592
- from matplotlib.colors import colorConverter, to_hex
593
-
594
- if c in ("None", "none"):
595
- return c
596
-
597
- _has_alpha = has_alpha(c)
598
- c = to_hex(c, keep_alpha=_has_alpha)
599
-
600
- if not _has_alpha:
601
- arr = colorConverter.to_rgba(c, a)
602
- return to_hex(arr, keep_alpha=True)
603
-
604
- return c
605
-
606
- if is_iterable(colors):
607
- colors = cast("Sequence[ColorType]", colors)
608
-
609
- if all(no_color(c) for c in colors):
610
- return "none"
611
-
612
- if isinstance(alpha, (Sequence, pd.Series)):
613
- return [to_rgba_hex(c, a) for c, a in zip(colors, alpha)]
614
- else:
615
- return [to_rgba_hex(c, alpha) for c in colors]
616
- else:
617
- colors = cast("ColorType", colors)
618
-
619
- if no_color(colors):
620
- return colors
621
-
622
- if isinstance(alpha, (Sequence, pd.Series)):
623
- return [to_rgba_hex(colors, a) for a in alpha]
624
- else:
625
- return to_rgba_hex(colors, alpha)
626
-
627
-
628
534
  def groupby_apply(
629
535
  df: pd.DataFrame,
630
536
  cols: str | list[str],
@@ -1227,11 +1133,11 @@ def default_field(default: T) -> T:
1227
1133
  return field(default_factory=lambda: deepcopy(default))
1228
1134
 
1229
1135
 
1230
- def get_opposite_side(s: SidePosition) -> SidePosition:
1136
+ def get_opposite_side(s: Side) -> Side:
1231
1137
  """
1232
1138
  Return the opposite side
1233
1139
  """
1234
- lookup: dict[SidePosition, SidePosition] = {
1140
+ lookup: dict[Side, Side] = {
1235
1141
  "right": "left",
1236
1142
  "left": "right",
1237
1143
  "top": "bottom",
@@ -1241,7 +1147,7 @@ def get_opposite_side(s: SidePosition) -> SidePosition:
1241
1147
 
1242
1148
 
1243
1149
  def ensure_xy_location(
1244
- loc: SidePosition | Literal["center"] | float | tuple[float, float],
1150
+ loc: Side | Literal["center"] | float | tuple[float, float],
1245
1151
  ) -> tuple[float, float]:
1246
1152
  """
1247
1153
  Convert input into (x, y) location
@@ -1264,3 +1170,40 @@ def ensure_xy_location(
1264
1170
  if isinstance(h, (int, float)) and isinstance(v, (int, float)):
1265
1171
  return (h, v)
1266
1172
  raise ValueError(f"Cannot make a location from '{loc}'")
1173
+
1174
+
1175
+ def ha_as_float(ha: HorizontalJustification | float) -> float:
1176
+ """
1177
+ Return horizontal alignment as a float
1178
+ """
1179
+ lookup = {"left": 0.0, "center": 0.5, "right": 1.0}
1180
+ return lookup[ha] if isinstance(ha, str) else ha
1181
+
1182
+
1183
+ def va_as_float(va: VerticalJustification | float) -> float:
1184
+ """
1185
+ Return vertical alignment as a float
1186
+ """
1187
+ lookup = {
1188
+ "top": 1.0,
1189
+ "center": 0.5,
1190
+ "bottom": 0.0,
1191
+ # baseline and center_baseline are valid for texts but we do
1192
+ # not handle them accurately
1193
+ "baseline": 0.5,
1194
+ "center_baseline": 0.5,
1195
+ }
1196
+ return lookup[va] if isinstance(va, str) else va
1197
+
1198
+
1199
+ def has_alpha_channel(c: str | tuple) -> bool:
1200
+ """
1201
+ Return True if c a color with an alpha value
1202
+
1203
+ Either a 9 character hex string e.g. #AABBCC88 or
1204
+ an RGBA tuple e.g. (.6, .7, .8, .5)
1205
+ """
1206
+ if isinstance(c, str):
1207
+ return c.startswith("#") and len(c) == 9
1208
+ else:
1209
+ return color_utils.is_color_tuple(c) and len(c) == 4