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.
Files changed (65) hide show
  1. plot_io/__init__.py +66 -0
  2. plot_io/_backend.py +114 -0
  3. plot_io/axis.py +574 -0
  4. plot_io/colorbar.py +342 -0
  5. plot_io/decorations.py +496 -0
  6. plot_io/defaults.py +766 -0
  7. plot_io/density.py +97 -0
  8. plot_io/enums.py +63 -0
  9. plot_io/facets.py +184 -0
  10. plot_io/figure.py +331 -0
  11. plot_io/labels.py +63 -0
  12. plot_io/legend.py +498 -0
  13. plot_io/limits.py +92 -0
  14. plot_io/maps/__init__.py +365 -0
  15. plot_io/maps/data.py +333 -0
  16. plot_io/markers.py +117 -0
  17. plot_io/numbers.py +289 -0
  18. plot_io/occupancy.py +219 -0
  19. plot_io/palettes.py +142 -0
  20. plot_io/plot.py +435 -0
  21. plot_io/render.py +1122 -0
  22. plot_io/scales.py +93 -0
  23. plot_io/series/__init__.py +498 -0
  24. plot_io/series/area.py +181 -0
  25. plot_io/series/bar.py +294 -0
  26. plot_io/series/box.py +401 -0
  27. plot_io/series/catdata.py +23 -0
  28. plot_io/series/catpoints.py +182 -0
  29. plot_io/series/contour.py +294 -0
  30. plot_io/series/curves.py +128 -0
  31. plot_io/series/ecdf.py +143 -0
  32. plot_io/series/errorbar.py +111 -0
  33. plot_io/series/errors.py +100 -0
  34. plot_io/series/fill.py +168 -0
  35. plot_io/series/heatmap.py +304 -0
  36. plot_io/series/hexbin.py +216 -0
  37. plot_io/series/hist.py +379 -0
  38. plot_io/series/hist2d.py +163 -0
  39. plot_io/series/kde.py +228 -0
  40. plot_io/series/kde2d.py +153 -0
  41. plot_io/series/line.py +173 -0
  42. plot_io/series/pie.py +304 -0
  43. plot_io/series/points.py +341 -0
  44. plot_io/series/regression.py +250 -0
  45. plot_io/series/scatter.py +103 -0
  46. plot_io/series/stem.py +112 -0
  47. plot_io/series/violin.py +321 -0
  48. plot_io/settings.py +281 -0
  49. plot_io/shapes.py +57 -0
  50. plot_io/sharing.py +160 -0
  51. plot_io/slots.py +52 -0
  52. plot_io/stacking.py +63 -0
  53. plot_io/stats.py +140 -0
  54. plot_io/style.py +71 -0
  55. plot_io/styles/__init__.py +234 -0
  56. plot_io/styles/library.py +732 -0
  57. plot_io/tables.py +141 -0
  58. plot_io/text.py +109 -0
  59. plot_io/ticks.py +284 -0
  60. plot_io/times.py +199 -0
  61. plot_io/values.py +161 -0
  62. plot_io-0.1.0.dist-info/METADATA +102 -0
  63. plot_io-0.1.0.dist-info/RECORD +65 -0
  64. plot_io-0.1.0.dist-info/WHEEL +4 -0
  65. 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