plot-io 0.1.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.
- plot_io/__init__.py +66 -0
- plot_io/_backend.py +114 -0
- plot_io/axis.py +574 -0
- plot_io/colorbar.py +342 -0
- plot_io/decorations.py +496 -0
- plot_io/defaults.py +766 -0
- plot_io/density.py +97 -0
- plot_io/enums.py +63 -0
- plot_io/facets.py +184 -0
- plot_io/figure.py +331 -0
- plot_io/labels.py +63 -0
- plot_io/legend.py +498 -0
- plot_io/limits.py +92 -0
- plot_io/maps/__init__.py +365 -0
- plot_io/maps/data.py +333 -0
- plot_io/markers.py +117 -0
- plot_io/numbers.py +289 -0
- plot_io/occupancy.py +219 -0
- plot_io/palettes.py +142 -0
- plot_io/plot.py +435 -0
- plot_io/render.py +1122 -0
- plot_io/scales.py +93 -0
- plot_io/series/__init__.py +498 -0
- plot_io/series/area.py +181 -0
- plot_io/series/bar.py +294 -0
- plot_io/series/box.py +401 -0
- plot_io/series/catdata.py +23 -0
- plot_io/series/catpoints.py +182 -0
- plot_io/series/contour.py +294 -0
- plot_io/series/curves.py +128 -0
- plot_io/series/ecdf.py +143 -0
- plot_io/series/errorbar.py +111 -0
- plot_io/series/errors.py +100 -0
- plot_io/series/fill.py +168 -0
- plot_io/series/heatmap.py +304 -0
- plot_io/series/hexbin.py +216 -0
- plot_io/series/hist.py +379 -0
- plot_io/series/hist2d.py +163 -0
- plot_io/series/kde.py +228 -0
- plot_io/series/kde2d.py +153 -0
- plot_io/series/line.py +173 -0
- plot_io/series/pie.py +304 -0
- plot_io/series/points.py +341 -0
- plot_io/series/regression.py +250 -0
- plot_io/series/scatter.py +103 -0
- plot_io/series/stem.py +112 -0
- plot_io/series/violin.py +321 -0
- plot_io/settings.py +281 -0
- plot_io/shapes.py +57 -0
- plot_io/sharing.py +160 -0
- plot_io/slots.py +52 -0
- plot_io/stacking.py +63 -0
- plot_io/stats.py +140 -0
- plot_io/style.py +71 -0
- plot_io/styles/__init__.py +234 -0
- plot_io/styles/library.py +732 -0
- plot_io/tables.py +141 -0
- plot_io/text.py +109 -0
- plot_io/ticks.py +284 -0
- plot_io/times.py +199 -0
- plot_io/values.py +161 -0
- plot_io-0.1.0.dist-info/METADATA +102 -0
- plot_io-0.1.0.dist-info/RECORD +65 -0
- plot_io-0.1.0.dist-info/WHEEL +4 -0
- plot_io-0.1.0.dist-info/licenses/LICENSE +28 -0
plot_io/series/box.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""Box plots: quartiles, whiskers and outliers per category."""
|
|
2
|
+
|
|
3
|
+
import array
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .. import _backend
|
|
7
|
+
from ..markers import check_marker, resolve
|
|
8
|
+
from ..settings import Setting, boolean, color, either, literal, number, one_of, pair, table
|
|
9
|
+
from ..style import desaturate, with_alpha
|
|
10
|
+
from . import Series, common
|
|
11
|
+
from .points import MarkerStyle, marker_settings
|
|
12
|
+
from ..values import Values, category_paint
|
|
13
|
+
from ..slots import slots
|
|
14
|
+
|
|
15
|
+
__all__ = ["Box"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _whiskers(v: Any) -> Any:
|
|
19
|
+
if v == "range":
|
|
20
|
+
return "range"
|
|
21
|
+
if isinstance(v, (tuple, list)):
|
|
22
|
+
lo, hi = pair(number(0, 100))(v)
|
|
23
|
+
if not lo < hi:
|
|
24
|
+
raise ValueError("whisker percentiles must increase")
|
|
25
|
+
return (lo, hi)
|
|
26
|
+
return number(lo=0)(v)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _line_color(v: Any) -> Any:
|
|
30
|
+
if v in ("series", "auto"):
|
|
31
|
+
return v
|
|
32
|
+
return color(v)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _dash(v: Any) -> Any:
|
|
36
|
+
if isinstance(v, (list, tuple)):
|
|
37
|
+
return [number(lo=0)(x) for x in v]
|
|
38
|
+
return one_of("solid", "dashed", "dotted", "dashdot")(v)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Box(Series):
|
|
42
|
+
"""Box plots of ``values``: a dict ``{category: values}``, a list of arrays
|
|
43
|
+
(categories ``0, 1, …`` or ``categories=``), or one array."""
|
|
44
|
+
|
|
45
|
+
SETTINGS = table(
|
|
46
|
+
color=Setting(category_paint,
|
|
47
|
+
doc="Box fill: a color, one per box (list or dict by category), or "
|
|
48
|
+
"numbers per box (list or dict) mapped through `color_map`; "
|
|
49
|
+
"default: the next palette color."),
|
|
50
|
+
**common("color_map"),
|
|
51
|
+
line_color=Setting(_line_color,
|
|
52
|
+
doc="Box outline, whiskers, caps and median: a color, `\"series\"` "
|
|
53
|
+
"(each box's own color) or `\"auto\"` (a grey 0.6 × as light as "
|
|
54
|
+
"the series' lightest color, as seaborn)."),
|
|
55
|
+
whisker_color=Setting(_line_color, doc="Whisker and cap color; default `line_color`."),
|
|
56
|
+
whisker_style=Setting(_dash,
|
|
57
|
+
doc="Whisker line style: `solid`, `dashed`, `dotted`, `dashdot` or "
|
|
58
|
+
"a dash list (caps stay solid)."),
|
|
59
|
+
line_width=Setting(number(lo=0), doc="Outline and whisker width (pt).", length=True),
|
|
60
|
+
median_color=Setting(_line_color, doc="Median line color; default `line_color`."),
|
|
61
|
+
median_width=Setting(number(lo=0), doc="Median line width (pt).", length=True),
|
|
62
|
+
width=Setting(number(positive=True),
|
|
63
|
+
doc="Width of a category's slot (category units)."),
|
|
64
|
+
cap_width=Setting(number(lo=0), doc="Whisker cap width, as a fraction of the box."),
|
|
65
|
+
whiskers=Setting(_whiskers,
|
|
66
|
+
doc="Whisker reach: a multiple of the IQR, `\"range\"`, or "
|
|
67
|
+
"percentiles `(lo, hi)`."),
|
|
68
|
+
outliers=Setting(boolean, doc="Draw points beyond the whiskers."),
|
|
69
|
+
saturation=Setting(number(0, 1),
|
|
70
|
+
doc="Saturation of the box fills (1: the colors as given; seaborn "
|
|
71
|
+
"uses 0.75)."),
|
|
72
|
+
mean=Setting(boolean, doc="Draw the mean as a marker."),
|
|
73
|
+
mean_marker=Setting(check_marker, doc="Marker of the mean."),
|
|
74
|
+
mean_color=Setting(color, doc="Fill of the mean marker (outline: "
|
|
75
|
+
"`line_color`)."),
|
|
76
|
+
orientation=Setting(one_of("vertical", "horizontal"),
|
|
77
|
+
doc="`vertical` (categories on x) or `horizontal`."),
|
|
78
|
+
dodge=Setting(boolean, doc="Box and violin series sharing a category share its "
|
|
79
|
+
"slot side by side; `False` overlays them."),
|
|
80
|
+
gap=Setting(number(0, 1), doc="Fraction of each box's part of a shared slot left "
|
|
81
|
+
"empty."),
|
|
82
|
+
categories=Setting(either(literal(None), lambda v: list(v)),
|
|
83
|
+
doc="Category names of a list of arrays."),
|
|
84
|
+
**marker_settings("marker_"),
|
|
85
|
+
**common("name", "alpha", "z", "rasterize", "simplify", "font_size", "font_family",
|
|
86
|
+
"text_color"),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
DODGE_GROUP = "box"
|
|
90
|
+
LONG_FORM = True
|
|
91
|
+
|
|
92
|
+
def __init__(self, values: Any, positions: Any = None, plot: Any = None,
|
|
93
|
+
**settings: Any) -> None:
|
|
94
|
+
cats = settings.get("categories")
|
|
95
|
+
if isinstance(values, dict):
|
|
96
|
+
items = list(values.items())
|
|
97
|
+
elif _is_sequence_of_arrays(values):
|
|
98
|
+
names = list(cats) if cats is not None else list(range(len(values)))
|
|
99
|
+
if len(names) != len(values):
|
|
100
|
+
raise ValueError(f"{len(names)} categories for {len(values)} boxes")
|
|
101
|
+
items = list(zip(names, values))
|
|
102
|
+
else:
|
|
103
|
+
items = [(cats[0] if cats else 0, values)]
|
|
104
|
+
self.cats = [k for k, _ in items]
|
|
105
|
+
self.data = [_backend.doubles(v) for _, v in items]
|
|
106
|
+
self.positions = None if positions is None else [float(p) for p in positions]
|
|
107
|
+
if self.positions is not None and len(self.positions) != len(self.cats):
|
|
108
|
+
raise ValueError(f"{len(self.positions)} positions for {len(self.cats)} boxes")
|
|
109
|
+
self._stats: dict[Any, Any] = {}
|
|
110
|
+
self._stats_key: Any = None
|
|
111
|
+
self._drawn: list[tuple[float, Any]] = []
|
|
112
|
+
super().__init__(plot=plot, **settings)
|
|
113
|
+
|
|
114
|
+
# -- statistics -------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def stats(self) -> dict[Any, Any]:
|
|
118
|
+
"""``{category: BoxStats}``: count, mean, q1, median, q3, low, high, outliers."""
|
|
119
|
+
key = self.get("whiskers")
|
|
120
|
+
if self._stats_key != key or not self._stats:
|
|
121
|
+
self._stats = {k: _backend.box_stats(v, key) for k, v in zip(self.cats, self.data)}
|
|
122
|
+
self._stats_key = key
|
|
123
|
+
return self._stats
|
|
124
|
+
|
|
125
|
+
def _horizontal(self) -> bool:
|
|
126
|
+
return self.get("orientation") == "horizontal"
|
|
127
|
+
|
|
128
|
+
def _cat_role(self) -> str:
|
|
129
|
+
return "y" if self._horizontal() else "x"
|
|
130
|
+
|
|
131
|
+
def categories(self, role: str) -> list[Any] | None:
|
|
132
|
+
if role == self._cat_role() and self.positions is None:
|
|
133
|
+
return list(self.cats)
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
def values(self, role: str) -> Any:
|
|
137
|
+
if role == self._cat_role():
|
|
138
|
+
return array.array("d", self.positions or [])
|
|
139
|
+
out = array.array("d")
|
|
140
|
+
show_out = self.get("outliers")
|
|
141
|
+
for st in self.stats.values():
|
|
142
|
+
if st.count:
|
|
143
|
+
out.extend((st.low, st.high, st.q1, st.q3))
|
|
144
|
+
if show_out:
|
|
145
|
+
out.extend(st.outliers)
|
|
146
|
+
if self.get("mean"):
|
|
147
|
+
out.append(st.mean)
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
def extent(self, role: str, scale: Any) -> tuple[int, float, float]:
|
|
151
|
+
if role == self._cat_role() and self.positions is not None:
|
|
152
|
+
w = self.get("width") / 2
|
|
153
|
+
n, lo, hi = _backend.extent(self.positions, scale.function)
|
|
154
|
+
return n, lo - w, hi + w
|
|
155
|
+
return super().extent(role, scale)
|
|
156
|
+
|
|
157
|
+
def zero_default(self, role: str) -> bool:
|
|
158
|
+
return role != self._cat_role()
|
|
159
|
+
|
|
160
|
+
def zero_padding(self, role: str) -> bool:
|
|
161
|
+
return role == self._cat_role() and self.positions is not None
|
|
162
|
+
|
|
163
|
+
def color_sources(self) -> list[tuple[Any, Any]]:
|
|
164
|
+
c = self.get("color")
|
|
165
|
+
if isinstance(c, Values):
|
|
166
|
+
return [(self.color_map_obj(), array.array("d", self._per_box(c.data)))]
|
|
167
|
+
return []
|
|
168
|
+
|
|
169
|
+
def _per_box(self, v: Any) -> list[Any]:
|
|
170
|
+
if isinstance(v, dict):
|
|
171
|
+
return [v.get(k, float("nan")) for k in self.cats]
|
|
172
|
+
if len(v) != len(self.cats):
|
|
173
|
+
raise ValueError(f"{len(v)} values for {len(self.cats)} boxes")
|
|
174
|
+
return list(v)
|
|
175
|
+
|
|
176
|
+
# -- geometry ---------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def _slots(self, ctx: Any) -> list[tuple[float, float]]:
|
|
179
|
+
"""``(center, width)`` of each box, in category units."""
|
|
180
|
+
return slots(self, ctx)
|
|
181
|
+
|
|
182
|
+
def _fills(self, ctx: Any) -> list[Any]:
|
|
183
|
+
alpha = self.get("alpha")
|
|
184
|
+
alpha = 1.0 if alpha is None else alpha
|
|
185
|
+
cols = self._base_colors(ctx)
|
|
186
|
+
sat = self.get("saturation")
|
|
187
|
+
if sat != 1.0:
|
|
188
|
+
cols = [desaturate(x, sat) if x is not None else None for x in cols]
|
|
189
|
+
return [with_alpha(x, alpha) if x is not None else None for x in cols]
|
|
190
|
+
|
|
191
|
+
def _lines(self, ctx: Any, setting: str, fallback: list[Any] | None = None) -> list[Any]:
|
|
192
|
+
"""Per-box colors of a line setting (`line_color`, `whisker_color`, `median_color`)."""
|
|
193
|
+
v = self.get(setting)
|
|
194
|
+
n = len(self.cats)
|
|
195
|
+
if v is None:
|
|
196
|
+
return list(fallback) if fallback is not None else [None] * n
|
|
197
|
+
if v == "series":
|
|
198
|
+
return self._base_colors(ctx)
|
|
199
|
+
if v == "auto":
|
|
200
|
+
import colorsys
|
|
201
|
+
from pdf_io.colors import to_rgba
|
|
202
|
+
rgbas = [to_rgba(c) for c in self._base_colors(ctx) if c is not None]
|
|
203
|
+
light = [colorsys.rgb_to_hls(r[0], r[1], r[2])[1] for r in rgbas if r is not None]
|
|
204
|
+
g = 0.6 * min(light) if light else 0.25
|
|
205
|
+
return [(g, g, g)] * n
|
|
206
|
+
return [ctx.color(v)] * n
|
|
207
|
+
|
|
208
|
+
def _base_colors(self, ctx: Any) -> list[Any]:
|
|
209
|
+
"""Each box's color before saturation and opacity."""
|
|
210
|
+
c = self.get("color")
|
|
211
|
+
n = len(self.cats)
|
|
212
|
+
if isinstance(c, Values):
|
|
213
|
+
cm = self.color_map_obj()
|
|
214
|
+
limits = ctx.maps[id(cm)]
|
|
215
|
+
from ..scales import get_scale
|
|
216
|
+
f = get_scale(cm.scale).f
|
|
217
|
+
vals = self._per_box(c.data)
|
|
218
|
+
span = (limits[1] - limits[0]) or 1.0
|
|
219
|
+
cols = [cm((f(v) - limits[0]) / span) for v in vals]
|
|
220
|
+
elif isinstance(c, dict):
|
|
221
|
+
cols = [ctx.color(c[k]) if c.get(k) is not None else None for k in self.cats]
|
|
222
|
+
elif isinstance(c, list):
|
|
223
|
+
if len(c) != n:
|
|
224
|
+
raise ValueError(f"{len(c)} colors for {n} boxes")
|
|
225
|
+
cols = [ctx.color(x) for x in c]
|
|
226
|
+
else:
|
|
227
|
+
cols = [self.base_color(ctx)] * n
|
|
228
|
+
return cols
|
|
229
|
+
|
|
230
|
+
def draw(self, ctx: Any) -> None:
|
|
231
|
+
canvas = ctx.canvas
|
|
232
|
+
horiz = self._horizontal()
|
|
233
|
+
cmap, vmap = (ctx.ymap, ctx.xmap) if horiz else (ctx.xmap, ctx.ymap)
|
|
234
|
+
lcs = self._lines(ctx, "line_color")
|
|
235
|
+
wcs = self._lines(ctx, "whisker_color", lcs)
|
|
236
|
+
mcs = [m if m is not None else lc
|
|
237
|
+
for m, lc in zip(self._lines(ctx, "median_color", lcs), lcs)]
|
|
238
|
+
lw = self.get("line_width")
|
|
239
|
+
mw = self.get("median_width")
|
|
240
|
+
wstyle = self.get("whisker_style")
|
|
241
|
+
dashed: dict[Any, tuple[list[float], list[float]]] = {}
|
|
242
|
+
fills = self._fills(ctx)
|
|
243
|
+
cap = self.get("cap_width")
|
|
244
|
+
# a0, b0, a1, b1, color, width of segments (a: across the boxes, b: along values)
|
|
245
|
+
seg: tuple[list[Any], ...] = ([], [], [], [], [], [])
|
|
246
|
+
outs_a = array.array("d")
|
|
247
|
+
outs_b = array.array("d")
|
|
248
|
+
means: list[tuple[float, float]] = []
|
|
249
|
+
self._drawn = []
|
|
250
|
+
stats = self.stats
|
|
251
|
+
lc = lcs[0] if lcs else None
|
|
252
|
+
for (center, width), k, fill, lc, wc, mc in zip(self._slots(ctx), self.cats, fills,
|
|
253
|
+
lcs, wcs, mcs):
|
|
254
|
+
st = stats[k]
|
|
255
|
+
if not st.count:
|
|
256
|
+
continue
|
|
257
|
+
a0, a1 = cmap(center - width / 2), cmap(center + width / 2)
|
|
258
|
+
ac = cmap(center)
|
|
259
|
+
q1, q3, med = vmap(st.q1), vmap(st.q3), vmap(st.median)
|
|
260
|
+
lo, hi = vmap(st.low), vmap(st.high)
|
|
261
|
+
self._drawn.append((center, st))
|
|
262
|
+
canvas.begin_path()
|
|
263
|
+
if horiz:
|
|
264
|
+
canvas.rect(min(q1, q3), min(a0, a1), abs(q3 - q1), abs(a1 - a0))
|
|
265
|
+
else:
|
|
266
|
+
canvas.rect(min(a0, a1), min(q1, q3), abs(a1 - a0), abs(q3 - q1))
|
|
267
|
+
if fill is not None:
|
|
268
|
+
canvas.fill(fill)
|
|
269
|
+
if lc is not None and lw:
|
|
270
|
+
canvas.line_width = lw
|
|
271
|
+
canvas.stroke(lc)
|
|
272
|
+
half = abs(a1 - a0) * cap / 2
|
|
273
|
+
for b0, b1 in ((q1, lo), (q3, hi)):
|
|
274
|
+
if wstyle == "solid":
|
|
275
|
+
seg[0].append(ac)
|
|
276
|
+
seg[1].append(b0)
|
|
277
|
+
seg[2].append(ac)
|
|
278
|
+
seg[3].append(b1)
|
|
279
|
+
seg[4].append(wc)
|
|
280
|
+
seg[5].append(lw)
|
|
281
|
+
elif wc is not None and lw:
|
|
282
|
+
xs_, ys_ = dashed.setdefault(wc, ([], []))
|
|
283
|
+
a_, b_ = (ys_, xs_) if horiz else (xs_, ys_)
|
|
284
|
+
a_ += [ac, ac, float("nan")]
|
|
285
|
+
b_ += [b0, b1, float("nan")]
|
|
286
|
+
if half:
|
|
287
|
+
seg[0].append(ac - half)
|
|
288
|
+
seg[1].append(b1)
|
|
289
|
+
seg[2].append(ac + half)
|
|
290
|
+
seg[3].append(b1)
|
|
291
|
+
seg[4].append(wc)
|
|
292
|
+
seg[5].append(lw)
|
|
293
|
+
seg[0].append(a0)
|
|
294
|
+
seg[1].append(med)
|
|
295
|
+
seg[2].append(a1)
|
|
296
|
+
seg[3].append(med)
|
|
297
|
+
seg[4].append(mc)
|
|
298
|
+
seg[5].append(mw)
|
|
299
|
+
if self.get("outliers") and len(st.outliers):
|
|
300
|
+
outs_a.extend([ac] * len(st.outliers))
|
|
301
|
+
outs_b.extend(vmap.many(st.outliers))
|
|
302
|
+
if self.get("mean"):
|
|
303
|
+
means.append((ac, vmap(st.mean)))
|
|
304
|
+
keep = [i for i in range(len(seg[0])) if seg[4][i] is not None and seg[5][i]]
|
|
305
|
+
if keep:
|
|
306
|
+
cols = [seg[i] for i in range(6)]
|
|
307
|
+
pick = [[c[i] for i in keep] for c in cols]
|
|
308
|
+
a0s, b0s, a1s, b1s, colors, widths = pick
|
|
309
|
+
if horiz:
|
|
310
|
+
canvas.draw_segments(b0s, a0s, b1s, a1s, color=colors, width=widths,
|
|
311
|
+
cap="butt")
|
|
312
|
+
else:
|
|
313
|
+
canvas.draw_segments(a0s, b0s, a1s, b1s, color=colors, width=widths,
|
|
314
|
+
cap="butt")
|
|
315
|
+
from ..style import dash_of
|
|
316
|
+
for wc, (xs_, ys_) in dashed.items():
|
|
317
|
+
canvas.draw_lines(xs_, ys_, color=wc, width=lw, dash=dash_of(wstyle, lw), cap="butt")
|
|
318
|
+
style = self._marker_style(ctx, len(outs_a))
|
|
319
|
+
if len(outs_a):
|
|
320
|
+
xs, ys = (outs_b, outs_a) if horiz else (outs_a, outs_b)
|
|
321
|
+
style.draw(canvas, xs, ys, self.simplify_arg())
|
|
322
|
+
if means:
|
|
323
|
+
mx = [b if horiz else a for a, b in means]
|
|
324
|
+
my = [a if horiz else b for a, b in means]
|
|
325
|
+
canvas.draw_markers(mx, my, marker=resolve(self.get("mean_marker"))[0],
|
|
326
|
+
size=style.size if isinstance(style.size, float) else 3.0,
|
|
327
|
+
fill=ctx.color(self.get("mean_color")), stroke=lc or "black",
|
|
328
|
+
line_width=self.get("marker_line_width"))
|
|
329
|
+
|
|
330
|
+
def _marker_style(self, ctx: Any, n: int, glyph: bool = False) -> MarkerStyle:
|
|
331
|
+
style = MarkerStyle(self, ctx, "marker_", None, n, glyph)
|
|
332
|
+
if style.fill is None and style.stroke is None:
|
|
333
|
+
style.stroke = self._lines(ctx, "line_color")[0] if self.cats else None
|
|
334
|
+
style.line_width = self.get("marker_line_width")
|
|
335
|
+
return style
|
|
336
|
+
|
|
337
|
+
def occupy(self, ctx: Any, occ: Any) -> None:
|
|
338
|
+
lo, hi, c0, c1, ox, oy = [], [], [], [], [], []
|
|
339
|
+
stats = self.stats
|
|
340
|
+
for (center, width), k in zip(self._slots(ctx), self.cats):
|
|
341
|
+
st = stats.get(k)
|
|
342
|
+
if st is None or not st.count:
|
|
343
|
+
continue
|
|
344
|
+
lo.append(st.low)
|
|
345
|
+
hi.append(st.high)
|
|
346
|
+
c0.append(center - width / 2)
|
|
347
|
+
c1.append(center + width / 2)
|
|
348
|
+
ox.extend([center] * len(st.outliers))
|
|
349
|
+
oy.extend(st.outliers)
|
|
350
|
+
if self._horizontal():
|
|
351
|
+
occ.rects(ctx.xmap.many(lo), ctx.ymap.many(c0), ctx.xmap.many(hi), ctx.ymap.many(c1))
|
|
352
|
+
occ.points(ctx.xmap.many(oy), ctx.ymap.many(ox))
|
|
353
|
+
else:
|
|
354
|
+
occ.rects(ctx.xmap.many(c0), ctx.ymap.many(lo), ctx.xmap.many(c1), ctx.ymap.many(hi))
|
|
355
|
+
occ.points(ctx.xmap.many(ox), ctx.ymap.many(oy))
|
|
356
|
+
|
|
357
|
+
def legend_points(self) -> tuple[Any, Any] | None:
|
|
358
|
+
xs, ys = array.array("d"), array.array("d")
|
|
359
|
+
for center, st in self._drawn:
|
|
360
|
+
for v in (st.low, st.q1, st.median, st.q3, st.high):
|
|
361
|
+
xs.append(center)
|
|
362
|
+
ys.append(v)
|
|
363
|
+
return (ys, xs) if self._horizontal() else (xs, ys)
|
|
364
|
+
|
|
365
|
+
def legend_colors(self, ctx: Any) -> tuple[Any, Any]:
|
|
366
|
+
fills = self._fills(ctx)
|
|
367
|
+
lines = self._lines(ctx, "line_color")
|
|
368
|
+
return (fills[0] if fills else None), (lines[0] if lines else None)
|
|
369
|
+
|
|
370
|
+
def legend_glyph(self, ctx: Any, x: float, y: float, w: float, h: float) -> None:
|
|
371
|
+
canvas = ctx.canvas
|
|
372
|
+
fills = self._fills(ctx)
|
|
373
|
+
fill = fills[0] if fills else None
|
|
374
|
+
lcs = self._lines(ctx, "line_color")
|
|
375
|
+
lc = lcs[0] if lcs else None
|
|
376
|
+
bw = min(w, 1.6 * h)
|
|
377
|
+
bx = x + (w - bw) / 2
|
|
378
|
+
canvas.begin_path()
|
|
379
|
+
canvas.rect(bx, y, bw, h)
|
|
380
|
+
if fill is not None:
|
|
381
|
+
canvas.fill(fill)
|
|
382
|
+
if lc is not None:
|
|
383
|
+
canvas.line_width = self.get("line_width")
|
|
384
|
+
canvas.stroke(lc)
|
|
385
|
+
mcs = self._lines(ctx, "median_color", lcs)
|
|
386
|
+
mc = mcs[0] if mcs and mcs[0] is not None else lc
|
|
387
|
+
canvas.draw_segments([bx], [y + h / 2], [bx + bw], [y + h / 2], color=mc,
|
|
388
|
+
width=min(self.get("median_width"), h / 3))
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _is_sequence_of_arrays(v: Any) -> bool:
|
|
392
|
+
if isinstance(v, (str, bytes)) or _backend.doubles is None:
|
|
393
|
+
return False
|
|
394
|
+
try:
|
|
395
|
+
mv = memoryview(v)
|
|
396
|
+
return mv.ndim == 2
|
|
397
|
+
except TypeError:
|
|
398
|
+
pass
|
|
399
|
+
if not isinstance(v, (list, tuple)) or not v:
|
|
400
|
+
return False
|
|
401
|
+
return all(hasattr(x, "__len__") and not isinstance(x, str) for x in v)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Per-category values of violin, strip and swarm series (the forms box plots take)."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .. import _backend
|
|
6
|
+
|
|
7
|
+
__all__ = ["category_values"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def category_values(values: Any, categories: Any = None) -> tuple[list[Any], list[Any]]:
|
|
11
|
+
"""``(categories, arrays)`` of a dict ``{category: values}``, a list of arrays
|
|
12
|
+
(categories ``0, 1, …`` or ``categories``), or one array."""
|
|
13
|
+
from .box import _is_sequence_of_arrays
|
|
14
|
+
if isinstance(values, dict):
|
|
15
|
+
items = list(values.items())
|
|
16
|
+
elif _is_sequence_of_arrays(values):
|
|
17
|
+
names = list(categories) if categories is not None else list(range(len(values)))
|
|
18
|
+
if len(names) != len(values):
|
|
19
|
+
raise ValueError(f"{len(names)} categories for {len(values)} value sets")
|
|
20
|
+
items = list(zip(names, values))
|
|
21
|
+
else:
|
|
22
|
+
items = [(categories[0] if categories else 0, values)]
|
|
23
|
+
return [k for k, _ in items], [_backend.doubles(v) for _, v in items]
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Points per category: jittered (strip) or side by side without overlaps (swarm)."""
|
|
2
|
+
|
|
3
|
+
import array
|
|
4
|
+
import math
|
|
5
|
+
import random
|
|
6
|
+
import warnings
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .. import stats
|
|
10
|
+
from ..settings import Setting, boolean, either, literal, number, one_of, table
|
|
11
|
+
from ..slots import slots
|
|
12
|
+
from . import Series, common
|
|
13
|
+
from .catdata import category_values
|
|
14
|
+
from .points import (MarkerStyle, density_points, marker_settings, order_index,
|
|
15
|
+
order_settings, permuted)
|
|
16
|
+
|
|
17
|
+
__all__ = ["Strip", "Swarm"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _CatPoints(Series):
|
|
21
|
+
SETTINGS: dict[str, Setting] = {}
|
|
22
|
+
DODGE_GROUP = "points"
|
|
23
|
+
LONG_FORM = True
|
|
24
|
+
|
|
25
|
+
def __init__(self, values: Any, positions: Any = None, plot: Any = None,
|
|
26
|
+
**settings: Any) -> None:
|
|
27
|
+
self.cats, self.data = category_values(values, settings.get("categories"))
|
|
28
|
+
self.positions = None if positions is None else [float(p) for p in positions]
|
|
29
|
+
if self.positions is not None and len(self.positions) != len(self.cats):
|
|
30
|
+
raise ValueError(f"{len(self.positions)} positions for {len(self.cats)} sets")
|
|
31
|
+
super().__init__(plot=plot, **settings)
|
|
32
|
+
|
|
33
|
+
def _horizontal(self) -> bool:
|
|
34
|
+
return self.get("orientation") == "horizontal"
|
|
35
|
+
|
|
36
|
+
def _cat_role(self) -> str:
|
|
37
|
+
return "y" if self._horizontal() else "x"
|
|
38
|
+
|
|
39
|
+
def categories(self, role: str) -> list[Any] | None:
|
|
40
|
+
if role == self._cat_role() and self.positions is None:
|
|
41
|
+
return list(self.cats)
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
def values(self, role: str) -> Any:
|
|
45
|
+
if role == self._cat_role():
|
|
46
|
+
return array.array("d", self.positions or [])
|
|
47
|
+
out = array.array("d")
|
|
48
|
+
for d in self.data:
|
|
49
|
+
out.extend(d)
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
def extent(self, role: str, scale: Any) -> tuple[int, float, float]:
|
|
53
|
+
if role == self._cat_role() and self.positions is not None:
|
|
54
|
+
from .. import _backend
|
|
55
|
+
w = self.get("width") / 2
|
|
56
|
+
n, lo, hi = _backend.extent(self.positions, scale.function)
|
|
57
|
+
return n, lo - w, hi + w
|
|
58
|
+
return super().extent(role, scale)
|
|
59
|
+
|
|
60
|
+
def _flat(self) -> tuple[list[int], array.array]:
|
|
61
|
+
cat_index, vals = [], array.array("d")
|
|
62
|
+
for k, d in enumerate(self.data):
|
|
63
|
+
cat_index.extend([k] * len(d))
|
|
64
|
+
vals.extend(d)
|
|
65
|
+
return cat_index, vals
|
|
66
|
+
|
|
67
|
+
def offsets(self, ctx: Any, centers: list[float], widths: list[float]) -> array.array:
|
|
68
|
+
raise NotImplementedError
|
|
69
|
+
|
|
70
|
+
def positions_page(self, ctx: Any) -> tuple[array.array, array.array]:
|
|
71
|
+
sl = slots(self, ctx)
|
|
72
|
+
centers = [c for c, _ in sl]
|
|
73
|
+
widths = [w for _, w in sl]
|
|
74
|
+
idx, vals = self._flat()
|
|
75
|
+
offs = self.offsets(ctx, centers, widths)
|
|
76
|
+
cat = array.array("d", (centers[k] + o for k, o in zip(idx, offs)))
|
|
77
|
+
return (vals, cat) if self._horizontal() else (cat, vals)
|
|
78
|
+
|
|
79
|
+
def legend_points(self) -> tuple[Any, Any] | None:
|
|
80
|
+
idx, vals = self._flat()
|
|
81
|
+
pos = self.positions or [float(i) for i in range(len(self.cats))]
|
|
82
|
+
cat = array.array("d", (pos[k] for k in idx))
|
|
83
|
+
return (vals, cat) if self._horizontal() else (cat, vals)
|
|
84
|
+
|
|
85
|
+
def _style(self, ctx: Any, n: int, glyph: bool = False) -> MarkerStyle:
|
|
86
|
+
return MarkerStyle(self, ctx, "", self.base_color(ctx), n, glyph)
|
|
87
|
+
|
|
88
|
+
def draw(self, ctx: Any) -> None:
|
|
89
|
+
xs, ys = self.positions_page(ctx)
|
|
90
|
+
idx = order_index(self, len(xs))
|
|
91
|
+
style = MarkerStyle(self, ctx, "", self.base_color(ctx), len(xs), False, idx)
|
|
92
|
+
px, py = ctx.xmap.many(xs), ctx.ymap.many(ys)
|
|
93
|
+
if idx is not None:
|
|
94
|
+
px, py = permuted(px, idx), permuted(py, idx)
|
|
95
|
+
style.draw(ctx.canvas, px, py, self.simplify_arg())
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def stats(self) -> dict[str, Any]:
|
|
99
|
+
"""Derived data: ``{"density": values}`` when the series maps a density."""
|
|
100
|
+
from .points import density_stats
|
|
101
|
+
return density_stats(self)
|
|
102
|
+
|
|
103
|
+
def density_points(self) -> tuple[Any, Any]:
|
|
104
|
+
pts: Any = self.legend_points()
|
|
105
|
+
return density_points(self, pts[0], pts[1])
|
|
106
|
+
|
|
107
|
+
def legend_colors(self, ctx: Any) -> tuple[Any, Any]:
|
|
108
|
+
style = self._style(ctx, 1, True)
|
|
109
|
+
return style.first_color("fill"), style.first_color("stroke")
|
|
110
|
+
|
|
111
|
+
def legend_glyph(self, ctx: Any, x: float, y: float, w: float, h: float) -> None:
|
|
112
|
+
self._style(ctx, 1, True).glyph(ctx.canvas, x + w / 2, y + h / 2, h * 1.1)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _settings(extra: dict[str, Setting]) -> dict[str, Setting]:
|
|
116
|
+
return table(
|
|
117
|
+
**marker_settings(),
|
|
118
|
+
**common("color_map", "size_map", "size_legend"),
|
|
119
|
+
orientation=Setting(one_of("vertical", "horizontal"),
|
|
120
|
+
doc="`vertical` (categories on x) or `horizontal`."),
|
|
121
|
+
width=Setting(number(positive=True), doc="Width of a category's slot."),
|
|
122
|
+
dodge=Setting(boolean, doc="Point series sharing a category share its slot "
|
|
123
|
+
"side by side; `False` overlays them."),
|
|
124
|
+
gap=Setting(number(0, 1), doc="Fraction of each part of a shared slot left "
|
|
125
|
+
"empty."),
|
|
126
|
+
categories=Setting(either(literal(None), lambda v: list(v)),
|
|
127
|
+
doc="Category names of a list of arrays."),
|
|
128
|
+
**extra,
|
|
129
|
+
**order_settings(),
|
|
130
|
+
**common("name", "z", "rasterize", "simplify", "font_size", "font_family",
|
|
131
|
+
"text_color"),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class Strip(_CatPoints):
|
|
136
|
+
"""Points of each category's values, spread across the slot by random jitter (from a
|
|
137
|
+
fixed seed, so output is reproducible)."""
|
|
138
|
+
|
|
139
|
+
SETTINGS = _settings(dict(
|
|
140
|
+
jitter=Setting(number(lo=0),
|
|
141
|
+
doc="Half-width of the jitter, as a fraction of the slot width."),
|
|
142
|
+
seed=Setting(number(integer=True), doc="Seed of the jitter."),
|
|
143
|
+
))
|
|
144
|
+
|
|
145
|
+
def offsets(self, ctx: Any, centers: list[float], widths: list[float]) -> array.array:
|
|
146
|
+
rng = random.Random(self.get("seed"))
|
|
147
|
+
j = self.get("jitter")
|
|
148
|
+
idx, _ = self._flat()
|
|
149
|
+
return array.array("d", (rng.uniform(-j, j) * widths[k] for k in idx))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Swarm(_CatPoints):
|
|
153
|
+
"""Points of each category's values, side by side so that they do not overlap
|
|
154
|
+
(points that do not fit go to the slot's edge, with a warning)."""
|
|
155
|
+
|
|
156
|
+
SETTINGS = _settings({})
|
|
157
|
+
|
|
158
|
+
def offsets(self, ctx: Any, centers: list[float], widths: list[float]) -> array.array:
|
|
159
|
+
horiz = self._horizontal()
|
|
160
|
+
cmap, vmap = (ctx.ymap, ctx.xmap) if horiz else (ctx.xmap, ctx.ymap)
|
|
161
|
+
unit = abs(cmap(1.0) - cmap(0.0)) or 1.0 # page units per category unit
|
|
162
|
+
style = self._style(ctx, sum(len(d) for d in self.data))
|
|
163
|
+
size = style.size
|
|
164
|
+
out = array.array("d")
|
|
165
|
+
gutter = 0
|
|
166
|
+
start = 0
|
|
167
|
+
for k, d in enumerate(self.data):
|
|
168
|
+
n = len(d)
|
|
169
|
+
sz = size if isinstance(size, float) else array.array("d", size[start:start + n])
|
|
170
|
+
start += n
|
|
171
|
+
pos = vmap.many(d)
|
|
172
|
+
offs, g = stats.swarm(pos, sz, widths[k] * unit)
|
|
173
|
+
gutter += g
|
|
174
|
+
sign = 1.0 if cmap(1.0) > cmap(0.0) else -1.0
|
|
175
|
+
out.extend(o / unit * sign for o in offs)
|
|
176
|
+
if gutter:
|
|
177
|
+
warnings.warn(f"{gutter} swarm points did not fit their slot and were placed at "
|
|
178
|
+
"its edge", stacklevel=2)
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
del math
|