plot3 0.4.0__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.
plot3/flip.py ADDED
@@ -0,0 +1,245 @@
1
+ """coord_flip: draw every layer with x and y exchanged.
2
+
3
+ Stats run in the usual orientation first (a boxplot summarises y by x).
4
+ Then bars, boxes, and filled densities are lowered to rectangles, lines, and
5
+ polygons, and every layer swaps its x and y. Both renderers then draw an
6
+ ordinary plot: no sideways drawing code is needed anywhere.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import copy
12
+ import math
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+
17
+ from plot3 import stat2d
18
+ from plot3.geoms import _Geom, aes
19
+
20
+ _SWAP = {
21
+ "x": "y", "y": "x", "xmin": "ymin", "ymin": "xmin",
22
+ "xmax": "ymax", "ymax": "xmax", "xend": "yend", "yend": "xend",
23
+ }
24
+
25
+
26
+ def swap_mapping(mapping) -> dict:
27
+ return {_SWAP.get(key, key): value for key, value in dict(mapping or {}).items()}
28
+
29
+
30
+ def flipped_figure(g):
31
+ """The ggplot with its own mapping, titles, and scales exchanged."""
32
+ out = copy.copy(g)
33
+ out.mapping = aes()
34
+ out.mapping.update(swap_mapping(g.mapping))
35
+ out.labs = {_SWAP.get(k, k): v for k, v in dict(g.labs).items()}
36
+ def moved(scale, axis):
37
+ if scale is None:
38
+ return None
39
+ scale = copy.copy(scale)
40
+ scale.axis = axis
41
+ return scale
42
+
43
+ out.xscale = moved(getattr(g, "yscale", None), "x")
44
+ out.yscale = moved(getattr(g, "xscale", None), "y")
45
+ out.scale_x, out.scale_y = getattr(g, "scale_y", None), getattr(g, "scale_x", None)
46
+ return out
47
+
48
+
49
+ def flip_refs(refs: list) -> list:
50
+ out = []
51
+ for ref in refs:
52
+ clone = copy.copy(ref)
53
+ if ref.kind == "hline":
54
+ clone.kind = "vline"
55
+ elif ref.kind == "vline":
56
+ clone.kind = "hline"
57
+ elif ref.kind == "abline":
58
+ # y = a + b x drawn with axes exchanged is x = a + b y.
59
+ if ref.slope == 0:
60
+ clone.kind = "vline"
61
+ clone.values = [ref.intercept]
62
+ else:
63
+ clone.slope = 1.0 / ref.slope
64
+ clone.intercept = -ref.intercept / ref.slope
65
+ out.append(clone)
66
+ return out
67
+
68
+
69
+ def flip_layers(layers: list, g) -> list:
70
+ out = []
71
+ for geom in layers:
72
+ for piece in _lower(geom, g):
73
+ out.append(_swap(piece))
74
+ return out
75
+
76
+
77
+ def _merged(geom, g) -> dict:
78
+ from plot3.build import _apply_fill
79
+
80
+ if getattr(geom, "_replace_mapping", False):
81
+ return dict(geom.mapping)
82
+ mapping = _apply_fill(dict(g.mapping), geom.kind)
83
+ mapping.update(_apply_fill(dict(geom.mapping), geom.kind))
84
+ return mapping
85
+
86
+
87
+ def _lower(geom, g) -> list:
88
+ kind = getattr(geom, "kind", "")
89
+ data = geom.data_override if getattr(geom, "data_override", None) is not None else g.data
90
+ if kind == "col":
91
+ mapping = _merged(geom, g)
92
+ proxy = _Geom(color=geom.const_color, alpha=geom.alpha)
93
+ proxy.kind = "col"
94
+ proxy.width = float(getattr(geom, "width", 0.9) or 0.9)
95
+ proxy.position = "identity"
96
+ bars = stat2d.positioned_bars(proxy, mapping, data)
97
+ bars._axis_labels = dict(getattr(geom, "_axis_labels", None) or {}) or getattr(bars, "_axis_labels", None)
98
+ return [bars]
99
+ if kind == "box":
100
+ return _lower_box(geom, g)
101
+ if kind == "area":
102
+ return [_lower_area(geom, g, data)]
103
+ if kind == "line" and getattr(geom, "sort_x", False) and getattr(geom, "_groups", None) is None:
104
+ # geom_line joins points in order of x; keep that order once x is vertical.
105
+ mapping = _merged(geom, g)
106
+ cols = [c for c in (mapping.get("x"), mapping.get("y"), mapping.get("color"), mapping.get("group")) if c]
107
+ from plot3.table import materialize_columns
108
+
109
+ frame = materialize_columns(data, cols)
110
+ keys = [c for c in (mapping.get("group"), mapping.get("color")) if c] + [mapping["x"]]
111
+ frame = frame.sort_values(keys, kind="stable").reset_index(drop=True)
112
+ clone = copy.copy(geom)
113
+ clone.data_override = frame
114
+ clone.sort_x = False
115
+ clone.mapping = aes()
116
+ clone.mapping.update(mapping)
117
+ clone._replace_mapping = True
118
+ return [clone]
119
+ return [geom]
120
+
121
+
122
+ def _lower_box(geom, g) -> list:
123
+ """A boxplot as a box polygon, median and whisker lines, and outliers."""
124
+ stats = geom.data_override
125
+ xcol = geom.mapping.get("x")
126
+ colour = geom.mapping.get("color")
127
+ colour = colour if colour and colour in stats.columns else None
128
+ axis = stat2d._axis(stats[xcol])
129
+ groups, levels = stat2d._colour_groups(stats, colour if colour != xcol else None)
130
+ n_groups = len(levels) if colour and colour != xcol else 1
131
+ width = float(getattr(geom, "width", 0.75)) / max(n_groups, 1)
132
+ offsets = stat2d._dodge_offsets(n_groups, float(getattr(geom, "width", 0.75)))
133
+ centres = axis.values + (offsets[groups] if n_groups > 1 else 0.0)
134
+ half = width / 2.0
135
+ ymin, lower, middle, upper, ymax = (stats[c].to_numpy(np.float64) for c in ("ymin", "lower", "middle", "upper", "ymax"))
136
+ keep = {colour: stats[colour].tolist()} if colour else {}
137
+ rx, ry, rect_groups, extra = stat2d._rect_rows(centres - half, centres + half, lower, upper, keep)
138
+ box_frame = pd.DataFrame({"x": axis.out(np.asarray(rx)), "y": ry})
139
+ box_map = {"x": "x", "y": "y"}
140
+ if colour:
141
+ box_frame[colour] = extra[colour]
142
+ box_map["colour"] = colour
143
+ box = stat2d._layer("poly", box_frame, box_map, geom, _groups=rect_groups, linewidth=1.2)
144
+ box.alpha = geom.alpha if geom.alpha is not None else 0.35
145
+ stat2d._levels_hook(box, axis)
146
+ lx, ly, lc, starts = [], [], [], []
147
+ for i in range(len(stats)):
148
+ c = centres[i]
149
+ for path_x, path_y in (
150
+ ([c - half, c + half], [middle[i], middle[i]]), # median
151
+ ([c, c], [ymin[i], lower[i]]), # lower whisker
152
+ ([c, c], [upper[i], ymax[i]]), # upper whisker
153
+ ):
154
+ if not all(math.isfinite(v) for v in path_y):
155
+ continue
156
+ starts.append([len(lx), 2])
157
+ lx += path_x
158
+ ly += path_y
159
+ if colour:
160
+ lc += [stats[colour].iloc[i]] * 2
161
+ line_frame = pd.DataFrame({"x": axis.out(np.asarray(lx)), "y": ly})
162
+ line_map = {"x": "x", "y": "y"}
163
+ if colour:
164
+ line_frame[colour] = lc
165
+ line_map["colour"] = colour
166
+ lines = stat2d._layer("line", line_frame, line_map, geom, _groups=starts, linewidth=1.5)
167
+ stat2d._levels_hook(lines, axis)
168
+ layers = [box, lines]
169
+ outliers = getattr(geom, "_outlier_frame", None)
170
+ y_name = getattr(geom, "_y_name", None)
171
+ if outliers is not None and len(outliers) and y_name in outliers.columns:
172
+ index = {str(level): i for i, level in enumerate(axis.levels or [])}
173
+ ox = np.array([index.get(str(v), np.nan) for v in outliers[xcol]], dtype=np.float64)
174
+ dots = pd.DataFrame({"x": ox, "y": outliers[y_name].to_numpy(np.float64)})
175
+ dot_map = {"x": "x", "y": "y"}
176
+ if colour and colour in outliers.columns:
177
+ dots[colour] = outliers[colour].to_numpy()
178
+ dot_map["colour"] = colour
179
+ dot = stat2d._layer("point", dots, dot_map, geom)
180
+ dot.size = float(getattr(geom, "outlier_size", 3.0))
181
+ stat2d._levels_hook(dot, axis)
182
+ layers.append(dot)
183
+ for layer in layers:
184
+ layer._axis_labels = {"x": xcol, "y": y_name or "y"}
185
+ return layers
186
+
187
+
188
+ def _lower_area(geom, g, data) -> _Geom:
189
+ """A filled density or area as a band from its baseline."""
190
+ mapping = _merged(geom, g)
191
+ xcol, ycol = mapping["x"], mapping["y"]
192
+ colour = mapping.get("color")
193
+ colour = colour if colour and colour in data.columns else None
194
+ frame = data
195
+ axis = stat2d._axis(frame[xcol])
196
+ baseline = float(getattr(geom, "_baseline", 0.0) or 0.0)
197
+ groups, levels = stat2d._colour_groups(frame, colour)
198
+ pieces = []
199
+ for gi in range(len(levels)):
200
+ rows = np.flatnonzero(groups == gi)
201
+ xs = axis.values[rows]
202
+ ys = frame[ycol].to_numpy(np.float64)[rows]
203
+ value = frame[colour].iloc[rows[0]] if colour and rows.size else None
204
+ pieces.append(stat2d._band(xs, np.full_like(ys, baseline), ys, value, colour, axis))
205
+ band = stat2d._bands_layer(pieces, colour, geom, axis, geom.alpha if geom.alpha is not None else 0.35)
206
+ band._axis_labels = dict(getattr(geom, "_axis_labels", None) or {"x": xcol, "y": ycol})
207
+ band._baseline_zero = True
208
+ return band
209
+
210
+
211
+ def _swap(geom) -> _Geom:
212
+ out = copy.copy(geom)
213
+ out.mapping = aes()
214
+ out.mapping.update(swap_mapping(geom.mapping))
215
+ out._violin_levels = getattr(geom, "_y_levels", None)
216
+ out._y_levels = getattr(geom, "_violin_levels", None)
217
+ labels = getattr(geom, "_axis_labels", None)
218
+ if labels:
219
+ out._axis_labels = {_SWAP.get(k, k): v for k, v in labels.items()}
220
+ if getattr(geom, "_annotations", None):
221
+ out._annotations = [dict(a, x=a["y"], y=a["x"]) for a in geom._annotations]
222
+ lock = getattr(geom, "_axis_lock", None)
223
+ if lock:
224
+ out._axis_lock = {_SWAP.get(k, k): v for k, v in lock.items()}
225
+ if hasattr(geom, "nudge_x") or hasattr(geom, "nudge_y"):
226
+ out.nudge_x, out.nudge_y = getattr(geom, "nudge_y", 0.0), getattr(geom, "nudge_x", 0.0)
227
+ if getattr(geom, "_x_domain", None) is not None:
228
+ out._x_domain = None
229
+ out.sort_x = False
230
+ return out
231
+
232
+
233
+ _SIDE_SWAP = {"b": "l", "l": "b", "t": "r", "r": "t"}
234
+
235
+
236
+ def flip_rugs(rugs: list) -> list:
237
+ """geom_rug under coord_flip: x values move to the left side, y to the bottom."""
238
+ out = []
239
+ for rug in rugs:
240
+ clone = copy.copy(rug)
241
+ clone.mapping = aes()
242
+ clone.mapping.update(swap_mapping(rug.mapping))
243
+ clone.sides = "".join(_SIDE_SWAP[side] for side in rug.sides)
244
+ out.append(clone)
245
+ return out