plot-io 0.12.0__py3-none-win_arm64.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 (130) hide show
  1. plot_io/__init__.py +73 -0
  2. plot_io/_methods.py +1691 -0
  3. plot_io/_native/__init__.py +199 -0
  4. plot_io/_native/__main__.py +197 -0
  5. plot_io/_native/_api.py +457 -0
  6. plot_io/_native/bin/windows-arm64.dll +0 -0
  7. plot_io/_native/kernels.py +335 -0
  8. plot_io/_native/manifest.json +49 -0
  9. plot_io/axis.py +1347 -0
  10. plot_io/boxes.py +1156 -0
  11. plot_io/canvas.py +389 -0
  12. plot_io/color/__init__.py +3 -0
  13. plot_io/color/data.py +1646 -0
  14. plot_io/color/maps.py +922 -0
  15. plot_io/color/values.py +295 -0
  16. plot_io/color/variants.py +1246 -0
  17. plot_io/colorbar.py +1426 -0
  18. plot_io/columns.py +614 -0
  19. plot_io/decorations.py +920 -0
  20. plot_io/defaults.py +809 -0
  21. plot_io/density.py +113 -0
  22. plot_io/ext.py +68 -0
  23. plot_io/facets.py +240 -0
  24. plot_io/figure.py +788 -0
  25. plot_io/formatters.py +97 -0
  26. plot_io/genome/__init__.py +17 -0
  27. plot_io/genome/adapters.py +356 -0
  28. plot_io/genome/combine.py +123 -0
  29. plot_io/genome/contacts.py +508 -0
  30. plot_io/genome/intervals.py +750 -0
  31. plot_io/genome/lanes.py +191 -0
  32. plot_io/genome/layout.py +184 -0
  33. plot_io/genome/links.py +364 -0
  34. plot_io/genome/pack.py +27 -0
  35. plot_io/genome/readers.py +145 -0
  36. plot_io/genome/regions.py +228 -0
  37. plot_io/genome/scale.py +349 -0
  38. plot_io/genome/series.py +344 -0
  39. plot_io/genome/signal.py +621 -0
  40. plot_io/genome/sites.py +782 -0
  41. plot_io/genome/sources.py +459 -0
  42. plot_io/genome/testing.py +161 -0
  43. plot_io/geometry.py +289 -0
  44. plot_io/groups.py +308 -0
  45. plot_io/guides.py +503 -0
  46. plot_io/helpers/__init__.py +17 -0
  47. plot_io/helpers/clustermap.py +202 -0
  48. plot_io/helpers/dendrogram.py +172 -0
  49. plot_io/helpers/grids.py +304 -0
  50. plot_io/helpers/qq.py +334 -0
  51. plot_io/helpers/raincloud.py +249 -0
  52. plot_io/helpers/tree.py +214 -0
  53. plot_io/helpers/volcano.py +505 -0
  54. plot_io/hints.py +125 -0
  55. plot_io/kernels.py +740 -0
  56. plot_io/labels.py +566 -0
  57. plot_io/layers/__init__.py +100 -0
  58. plot_io/layers/base.py +630 -0
  59. plot_io/layers/context.py +87 -0
  60. plot_io/layers/core.py +446 -0
  61. plot_io/layers/geoms.py +1362 -0
  62. plot_io/layers/grammar.py +586 -0
  63. plot_io/layers/paint.py +614 -0
  64. plot_io/layers/pipeline.py +170 -0
  65. plot_io/layers/positions.py +431 -0
  66. plot_io/layers/stats.py +1088 -0
  67. plot_io/layout.py +1381 -0
  68. plot_io/legend.py +605 -0
  69. plot_io/levels.py +509 -0
  70. plot_io/limits.py +124 -0
  71. plot_io/lines.py +42 -0
  72. plot_io/mapped.py +522 -0
  73. plot_io/markers.py +110 -0
  74. plot_io/markup.py +128 -0
  75. plot_io/numbers.py +354 -0
  76. plot_io/occupancy.py +295 -0
  77. plot_io/plot.py +597 -0
  78. plot_io/presets/__init__.py +51 -0
  79. plot_io/presets/_util.py +153 -0
  80. plot_io/presets/area.py +145 -0
  81. plot_io/presets/bar.py +261 -0
  82. plot_io/presets/box.py +400 -0
  83. plot_io/presets/catpoints.py +177 -0
  84. plot_io/presets/contour.py +353 -0
  85. plot_io/presets/curves.py +30 -0
  86. plot_io/presets/dots.py +355 -0
  87. plot_io/presets/ecdf.py +97 -0
  88. plot_io/presets/errorbar.py +106 -0
  89. plot_io/presets/errors.py +296 -0
  90. plot_io/presets/fill.py +74 -0
  91. plot_io/presets/heatmap.py +322 -0
  92. plot_io/presets/hexbin.py +198 -0
  93. plot_io/presets/hist.py +391 -0
  94. plot_io/presets/hist2d.py +87 -0
  95. plot_io/presets/kde.py +162 -0
  96. plot_io/presets/kde2d.py +147 -0
  97. plot_io/presets/line.py +143 -0
  98. plot_io/presets/pie.py +502 -0
  99. plot_io/presets/regression.py +115 -0
  100. plot_io/presets/scatter.py +194 -0
  101. plot_io/presets/stem.py +100 -0
  102. plot_io/presets/violin.py +323 -0
  103. plot_io/py.typed +0 -0
  104. plot_io/registry.py +82 -0
  105. plot_io/render.py +480 -0
  106. plot_io/rows.py +491 -0
  107. plot_io/scales.py +103 -0
  108. plot_io/seams.py +138 -0
  109. plot_io/settings.py +807 -0
  110. plot_io/shapes.py +66 -0
  111. plot_io/sharing.py +232 -0
  112. plot_io/sizemaps.py +97 -0
  113. plot_io/spec.py +240 -0
  114. plot_io/states.py +216 -0
  115. plot_io/styles/__init__.py +307 -0
  116. plot_io/styles/library.py +397 -0
  117. plot_io/tables.py +138 -0
  118. plot_io/text.py +412 -0
  119. plot_io/ticks.py +400 -0
  120. plot_io/times.py +287 -0
  121. plot_io/titles.py +225 -0
  122. plot_io/transform.py +114 -0
  123. plot_io/types.py +13604 -0
  124. plot_io/units.py +768 -0
  125. plot_io/validators.py +1029 -0
  126. plot_io/values.py +190 -0
  127. plot_io-0.12.0.dist-info/METADATA +215 -0
  128. plot_io-0.12.0.dist-info/RECORD +130 -0
  129. plot_io-0.12.0.dist-info/WHEEL +4 -0
  130. plot_io-0.12.0.dist-info/licenses/LICENSE +28 -0
@@ -0,0 +1,170 @@
1
+ """The stages between the model and the drawing: compute, positions, train.
2
+
3
+ * :func:`plot_calls`: each call of a plot through its preset (stats computed, layers
4
+ built), then the plot's positions (:func:`~plot_io.layers.positions.run`); cached in
5
+ the render in progress.
6
+ * :func:`train_colors`: the colors of the calls without a color of their own, from the
7
+ plot's ``qualitative_map`` in call order (a series' own ``color_map``: its sets, group
8
+ members and levels), continued across twin plots (a call that needs no hue takes
9
+ ``plain_color`` when set: :func:`plain_of`); kept in the render (``Render.colors``).
10
+ * :func:`drawn_layers`: a plot's layers in drawing order (``z``, then call order).
11
+ """
12
+
13
+ from typing import Any
14
+
15
+ from ..spec import current
16
+
17
+ __all__ = ["plot_calls", "call_of", "drawn_layers", "train_colors", "plain_of", "own_map", "z_of",
18
+ "render_of"]
19
+
20
+
21
+ def render_of(plot: Any) -> Any:
22
+ """The render in progress of ``plot``'s figure, if any."""
23
+ r = current()
24
+ return r if r is not None and r.root is getattr(plot, "figure", None) else None
25
+
26
+
27
+ def plot_calls(plot: Any) -> list[Any]:
28
+ """The calls of ``plot``'s series, positions applied (one per series, in call order)."""
29
+ r = render_of(plot)
30
+ if r is not None:
31
+ got = r.plots.get(id(plot))
32
+ if got is not None:
33
+ return got
34
+ from . import positions
35
+ from ..levels import calls_of
36
+ made = [(s, calls_of(s)) for s in plot.series]
37
+ calls = [c for _, cs in made for c in cs]
38
+ positions.run(plot, calls)
39
+ if r is not None:
40
+ r.plots[id(plot)] = calls
41
+ for s, cs in made:
42
+ for c in cs:
43
+ r.calls.setdefault(id(c.series), c)
44
+ r.calls[id(s)] = cs[0]
45
+ return calls
46
+
47
+
48
+ def call_of(series: Any) -> Any:
49
+ """The call of one series (its plot's calls are computed together)."""
50
+ r = render_of(series.plot)
51
+ if r is not None:
52
+ got = r.calls.get(id(series))
53
+ if got is not None:
54
+ return got
55
+ calls = plot_calls(series.plot)
56
+ return next(c for c in calls if c.series is series or c.series.__dict__.get("_of") is series)
57
+
58
+
59
+ def z_of(item: Any) -> float:
60
+ """Drawing order key: ``z``, unset (call order) counting as 0 (sorting is stable)."""
61
+ z = item.resolve("z")
62
+ return 0.0 if z is None else z
63
+
64
+
65
+ def drawn_layers(plot: Any) -> list[tuple[Any, Any]]:
66
+ """``(call, layer)`` of the plot's layers drawn inside the axes, in drawing order."""
67
+ calls = sorted(plot_calls(plot), key=lambda c: z_of(c.series))
68
+ return [(c, layer) for c in calls for layer in c.layers if not layer.overlay]
69
+
70
+
71
+ def _refs(series: Any) -> list[int]:
72
+ """References to the plot's colors (``"C1"``…) among a series' settings: the plot's
73
+ colors must hold them."""
74
+ from ..units import given
75
+ vals = (given(v) for v in series._values.values() if not isinstance(v, (list, tuple, dict)))
76
+ return [int(v[1:]) + 1 for v in vals
77
+ if isinstance(v, str) and v[:1] == "C" and v[1:].isascii() and v[1:].isdigit()]
78
+
79
+
80
+ def plain_of(call: Any) -> Any:
81
+ """The ``plain_color`` of a call that needs no hue (no levels, one color of the plot's,
82
+ no name: nothing in the legend tells it apart), else ``None``."""
83
+ s = call.series
84
+ if call.level is not None or call.color_count != 1 or "plain_color" not in s.SETTINGS:
85
+ return None
86
+ if "name" in s.SETTINGS and s.resolve("name") not in (None, ""):
87
+ return None
88
+ return s.resolve("plain_color")
89
+
90
+
91
+ def own_map(series: Any) -> Any:
92
+ """The colors a series' own ``color_map`` gives its sets, group members and levels: a
93
+ ``ColorMap`` (in the series' color variant), a dict level → color, or ``None`` (none set,
94
+ or ``"auto"``: the plot's colors)."""
95
+ from ..color.maps import chosen_map
96
+ if "color_map" not in series.SETTINGS or not series.is_set("color_map"):
97
+ return None
98
+ v = series.resolve("color_map")
99
+ if isinstance(v, dict):
100
+ return v
101
+ if v == "auto":
102
+ return None
103
+ return chosen_map(series, v, "qualitative")
104
+
105
+
106
+ def train_colors(plot: Any, calls: list[Any], twin_calls: list[Any], cycle: Any) -> Any:
107
+ """Colors for the calls without their own (``Call.color_count`` each), in call order,
108
+ continued in the twins' calls; ``cycle``: the main plot's colors (twins use them). The
109
+ colors are the plot's ``qualitative_map``'s, in each series' color variant; a series'
110
+ own ``color_map`` colors its sets and its group's members (:func:`own_map`). A call
111
+ that needs no hue takes its ``plain_color`` when set (:func:`plain_of`) and leaves the
112
+ plot's turn where it is. Returns the plot's colors (``"C<n>"`` refer to them)."""
113
+ from ..color.maps import cycle_of, map_of
114
+ from ..color.values import resolve_color, variant_of
115
+ from ..levels import assign, level_color_count
116
+ colored = calls + twin_calls
117
+ plain = {id(c): v for c in colored if (v := plain_of(c)) is not None}
118
+ if cycle is not None and getattr(plot, "twin_of", None):
119
+ return cycle
120
+ n_auto = sum(c.color_count for c in colored if id(c) not in plain) + \
121
+ level_color_count(colored)
122
+ n = max([n_auto] + [k for c in colored for k in _refs(c.series)])
123
+ base = map_of(plot, "qualitative", "base")
124
+ variants: dict[str, Any] = {}
125
+
126
+ def cycle_in(series: Any) -> Any:
127
+ v = variant_of(series)
128
+ got = variants.get(v)
129
+ if got is None:
130
+ got = variants[v] = cycle_of(base.in_variant(v), n)
131
+ return got
132
+ cycle = cycle_in(plot)
133
+ r = current()
134
+ assert r is not None
135
+ i = 0
136
+ done: dict[Any, Any] = {}
137
+ for c in colored:
138
+ s = c.series
139
+ if c.level is not None:
140
+ i = assign(plot, c, cycle_in(s), i, done)
141
+ elif id(c) in plain:
142
+ r.colors[id(s)] = [resolve_color(plain[id(c)], cycle)]
143
+ done[id(s)] = r.colors[id(s)]
144
+ elif c.color_count:
145
+ own = _own_colors(s, c.color_count)
146
+ if own is not None:
147
+ r.colors[id(s)] = own
148
+ else:
149
+ pal = cycle_in(s)
150
+ r.colors[id(s)] = [pal[(i + j) % len(pal)] for j in range(c.color_count)]
151
+ i += c.color_count
152
+ done[id(s)] = r.colors[id(s)]
153
+ else:
154
+ r.colors[id(s)] = None
155
+ return cycle
156
+
157
+
158
+ def _own_colors(series: Any, count: int) -> list[Any] | None:
159
+ """The colors of a series' own map for its ``count`` sets, or for its place among the
160
+ members of its ``group=`` call; ``None``: the plot's colors."""
161
+ member = series.__dict__.get("_member")
162
+ if count == 1 and member is None:
163
+ return None
164
+ cm = own_map(series)
165
+ if cm is None or isinstance(cm, dict):
166
+ return None
167
+ if member is not None:
168
+ k, total = member
169
+ return [cm.colors(total)[k]]
170
+ return list(cm.colors(count))
@@ -0,0 +1,431 @@
1
+ """Positions: what places a layer's items among the other layers of its plot.
2
+
3
+ They run once per plot, over all its calls, after the stats and before training
4
+ (:func:`run`):
5
+
6
+ * **stack** (:func:`stack`): tables with ``stack`` set, ``(group, mode[, baseline])``,
7
+ pile on the previous stacked tables of their group (family and orientation, or one
8
+ call), per key (a category, a bin, a grid point); positive and negative values pile
9
+ apart; ``"normalize"`` on any of them divides each key by its total (of absolute
10
+ values). With a ``baseline`` (``zero``, ``sym``, ``wiggle``, ``weighted_wiggle``: areas)
11
+ the tables' values at each index pile in order from the baseline, signs together.
12
+ Writes ``bottom`` and ``top``.
13
+ * **dodge**: tables of one family (categorical calls: box and violin, bar, points; the sets
14
+ of a histogram) with the same orientation share each slot (a category, or a bin) side
15
+ by side, in call order (stacked tables count as one, and so do split violins). Sets
16
+ ``parts``: per slot, the part ``(j, m)`` the table takes (``None``: the whole slot). The
17
+ slots' centres and widths need the axis scale (widths in px, `%` of the slot):
18
+ :func:`slots` computes them when the axes are placed, :func:`bin_parts` the parts of
19
+ bins.
20
+ * **jitter**: a uniform draw in [-1, 1] per point from the call's seed (``jitter_u``),
21
+ scaled by the jitter width once it is known.
22
+ * **swarm** places points side by side once marker sizes and the axes are known
23
+ (:func:`swarm_offsets`, a lazy column).
24
+ """
25
+
26
+ import array
27
+ import math
28
+ import random
29
+ import warnings
30
+ from typing import Any
31
+
32
+ from ..units import NeedsScale
33
+ from ..validators import typed
34
+
35
+ __all__ = ["NAMES", "run", "register", "check_stack", "stack", "slots", "bin_parts", "Dodge",
36
+ "Placed", "placed", "swarm_offsets"]
37
+
38
+ #: Positions of plot types registered with :func:`register` (genome signal bands).
39
+ _EXTRA: list[Any] = []
40
+
41
+ #: The position names a layer may declare (:func:`register` adds more).
42
+ NAMES: list[str] = ["identity", "stack", "dodge", "jitter", "swarm", "pack"]
43
+
44
+
45
+ def register(position: Any, name: str | None = None) -> None:
46
+ """Add a position: ``position(plot, calls)`` runs after the built-in ones, before the
47
+ presets' steps that need them; ``name``: the name layers declare it by."""
48
+ if position not in _EXTRA:
49
+ _EXTRA.append(position)
50
+ if name is not None and name not in NAMES:
51
+ NAMES.append(name)
52
+
53
+
54
+ @typed("bool | Literal['normalize']")
55
+ def check_stack(v: Any) -> Any:
56
+ if v == "normalize":
57
+ return "normalize"
58
+ if isinstance(v, bool):
59
+ return v
60
+ raise ValueError(f"expected True, False or 'normalize', got {v!r}")
61
+
62
+
63
+ class Dodge:
64
+ """A table's place in a dodge family: the series (its ``width``, ``gap``,
65
+ ``max_width`` and ``dodge`` settings), its categories, numeric ``positions`` (not
66
+ dodged), and the token it shares a part with (``"stack"``, ``"split"``, or itself)."""
67
+
68
+ __slots__ = ("series", "family", "flip", "cats", "positions", "token", "time", "parts",
69
+ "side")
70
+
71
+ def __init__(self, series: Any, family: Any, flip: bool, cats: list[Any],
72
+ positions: Any = None, token: Any = None, time: bool = False) -> None:
73
+ self.series = series
74
+ self.family = family
75
+ self.flip = flip
76
+ self.cats = cats
77
+ self.positions = positions
78
+ self.token = series if token is None else token
79
+ self.time = time
80
+ self.parts: list[Any] = []
81
+ #: split halves (``token="split"``): -1 the lower half, 1 the upper, 0 both
82
+ self.side = 0
83
+
84
+
85
+ def _tables(calls: list[Any]) -> list[Any]:
86
+ seen: set[int] = set()
87
+ out = []
88
+ for call in calls:
89
+ for layer in call.layers:
90
+ t = layer.table
91
+ if id(t) not in seen:
92
+ seen.add(id(t))
93
+ out.append(t)
94
+ for t in getattr(call, "tables", ()):
95
+ if id(t) not in seen:
96
+ seen.add(id(t))
97
+ out.append(t)
98
+ return out
99
+
100
+
101
+ def run(plot: Any, calls: list[Any]) -> None:
102
+ """The positions of a plot's calls (in call order)."""
103
+ tables = _tables(calls)
104
+ _stack(tables)
105
+ _dodge(tables)
106
+ for extra in _EXTRA:
107
+ extra(plot, calls)
108
+ for call in calls:
109
+ for step in call.after:
110
+ step()
111
+
112
+
113
+ def _stack(tables: list[Any]) -> None:
114
+ groups: dict[Any, list[Any]] = {}
115
+ for t in tables:
116
+ st = getattr(t, "stack", None)
117
+ if st:
118
+ groups.setdefault(st[0], []).append(t)
119
+ for members in groups.values():
120
+ normalize = any(t.stack[1] == "normalize" for t in members)
121
+ baseline = next((t.stack[2] for t in members if len(t.stack) > 2), None)
122
+ stack(members, normalize, baseline)
123
+
124
+
125
+ def stack(members: list[Any], normalize: bool = False, baseline: str | None = None) -> None:
126
+ """Pile ``members`` (tables with ``heights``, and ``keys`` without a ``baseline``) in
127
+ order: writes their ``bottom`` and ``top`` (see the module docstring)."""
128
+ if baseline is not None:
129
+ _pile(members, baseline)
130
+ return
131
+ totals: dict[Any, float] = {}
132
+ if normalize:
133
+ for t in members:
134
+ for k, v in zip(t["keys"], t["heights"]):
135
+ if math.isfinite(v):
136
+ totals[k] = totals.get(k, 0.0) + abs(v)
137
+ pos: dict[Any, float] = {}
138
+ neg: dict[Any, float] = {}
139
+ for t in members:
140
+ bottoms, tops = [], []
141
+ for k, v in zip(t["keys"], t["heights"]):
142
+ if not math.isfinite(v):
143
+ bottoms.append(math.nan)
144
+ tops.append(math.nan)
145
+ continue
146
+ if normalize:
147
+ tot = totals.get(k, 0.0)
148
+ v = v / tot if tot else 0.0
149
+ acc = pos if v >= 0 else neg
150
+ b = acc.get(k, 0.0)
151
+ acc[k] = b + v
152
+ bottoms.append(b)
153
+ tops.append(b + v)
154
+ t["bottom"], t["top"] = bottoms, tops
155
+
156
+
157
+ def _pile(members: list[Any], baseline: str) -> None:
158
+ """The values at each index piled from ``baseline`` (missing values count 0; their
159
+ tops are NaN): ``zero``, ``sym`` (symmetric around 0), ``wiggle`` or
160
+ ``weighted_wiggle`` (streamgraphs, as matplotlib's stackplot)."""
161
+ raws = [t["heights"] for t in members]
162
+ n = len(raws[0]) if raws else 0
163
+ ys = [[v if math.isfinite(v) else 0.0 for v in r] for r in raws]
164
+ m = len(ys)
165
+ total = [math.fsum(r[j] for r in ys) for j in range(n)]
166
+ if baseline == "zero":
167
+ first = [0.0] * n
168
+ elif baseline == "sym":
169
+ first = [-t / 2 for t in total]
170
+ elif baseline == "wiggle":
171
+ first = [-math.fsum(ys[i][j] * (m - 0.5 - i) for i in range(m)) / m
172
+ for j in range(n)]
173
+ else:
174
+ inv = [1.0 / t if t else 0.0 for t in total]
175
+ center = [0.0] * n
176
+ acc = 0.0
177
+ for j in range(n):
178
+ s = 0.0
179
+ cum = 0.0
180
+ for i in range(m):
181
+ cum += ys[i][j]
182
+ inc = ys[i][j] - (ys[i][j - 1] if j else 0.0) if j else ys[i][j]
183
+ below = total[j] - cum + 0.5 * ys[i][j]
184
+ move_up = 0.5 if j == 0 else below * inv[j]
185
+ s += (move_up - 0.5) * inc
186
+ acc += s
187
+ center[j] = acc
188
+ first = [c - 0.5 * t for c, t in zip(center, total)]
189
+ cur = first
190
+ for t, r, raw in zip(members, ys, raws):
191
+ top = [a + b for a, b in zip(cur, r)]
192
+ t["bottom"] = array.array("d", cur)
193
+ t["top"] = array.array("d", (v if math.isfinite(h) else math.nan
194
+ for v, h in zip(top, raw)))
195
+ cur = top
196
+
197
+
198
+ def _dodge(tables: list[Any]) -> None:
199
+ infos = [t.dodge for t in tables if getattr(t, "dodge", None) is not None]
200
+ for flip in (False, True): # split halves alternate, in call order
201
+ halves = [d for d in infos if d.token == "split" and d.flip == flip]
202
+ for i, d in enumerate(halves):
203
+ d.side = -1 if i % 2 == 0 else 1
204
+ families: dict[Any, list[Dodge]] = {}
205
+ for d in infos:
206
+ if d.positions is not None:
207
+ d.parts = [None] * len(d.cats)
208
+ else:
209
+ families.setdefault((d.family, d.flip), []).append(d)
210
+ for peers in families.values():
211
+ # per category, the tokens of the dodging tables that have it, in call order
212
+ members: dict[Any, dict[Any, int]] = {}
213
+ dodging = [bool(e.series.resolve("dodge")) for e in peers]
214
+ for e, on in zip(peers, dodging):
215
+ if not on:
216
+ continue
217
+ for k in e.cats:
218
+ toks = members.setdefault(k, {})
219
+ if e.token not in toks:
220
+ toks[e.token] = len(toks)
221
+ for d, on in zip(peers, dodging):
222
+ if not on:
223
+ d.parts = [None] * len(d.cats)
224
+ continue
225
+ d.parts = [(members[k][d.token], len(members[k])) for k in d.cats]
226
+
227
+
228
+ def bin_parts(d: Dodge, lo: Any, hi: Any, scale: Any) -> tuple[list[float], list[float]]:
229
+ """``(x0, x1)`` of the bins ``lo``–``hi`` (one per of ``d.cats``) for the table's
230
+ parts: even in the space of the axis ``scale``."""
231
+ x0, x1 = [], []
232
+ for a, b, part in zip(lo, hi, d.parts):
233
+ if part is None:
234
+ x0.append(a)
235
+ x1.append(b)
236
+ continue
237
+ j, m = part
238
+ fa = scale.f(a)
239
+ fd = (scale.f(b) - fa) / m
240
+ x0.append(scale.inverse(fa + j * fd))
241
+ x1.append(scale.inverse(fa + (j + 1) * fd))
242
+ return x0, x1
243
+
244
+
245
+ def slots(d: Dodge, ctx: Any) -> list[tuple[float, float]]:
246
+ """``(center, width)`` of each of ``d.cats`` in category units (data units on a
247
+ numeric axis), for the axes of ``ctx``."""
248
+ from .base import slot_width
249
+ s = d.series
250
+ state = ctx.y if d.flip else ctx.x
251
+ scale = abs((ctx.ymap if d.flip else ctx.xmap).a) or None
252
+ width = slot_width(s, d.positions, d.time, scale)
253
+ cap = math.inf
254
+ if "max_width" in s.SETTINGS and s.get("max_width") is not None:
255
+ cap = slot_width(s, d.positions, d.time, scale, "max_width") or math.inf
256
+ if d.positions is not None:
257
+ return [(p, min(width, cap)) for p in d.positions]
258
+ gap = (0.0, 0.0)
259
+ if "gap" in s.SETTINGS:
260
+ try:
261
+ gap = s.resolve_linear("gap", scale=scale, unit="data")
262
+ except NeedsScale:
263
+ pass
264
+ index: dict[Any, int] = {}
265
+ for i, c in enumerate(state.categories):
266
+ index.setdefault(c, i)
267
+ out = []
268
+ for k, part in zip(d.cats, d.parts):
269
+ i = index[k]
270
+ if part is None:
271
+ out.append((float(i), min(width, cap)))
272
+ continue
273
+ j, m = part
274
+ share = width / m
275
+ p = max(share - gap[0] - gap[1] * share, 0.0) if m > 1 else share
276
+ out.append((i - width / 2 + (j + 0.5) * share, min(p, cap)))
277
+ return out
278
+
279
+
280
+ class Placed:
281
+ """The slots of a dodged table placed in a drawing context: ``geo``, per category
282
+ ``(centre, width)`` in slot space (category units, data units on a numeric axis; at
283
+ numeric positions on an axis with a scale function, the scale's space, as
284
+ :func:`slot_edges`); :meth:`back` maps slot-space values to data, ``map`` is the
285
+ category axis' mapper and :meth:`px` its page coordinate of a slot-space value."""
286
+
287
+ __slots__ = ("geo", "inverse", "map")
288
+
289
+ def __init__(self, d: Dodge, ctx: Any) -> None:
290
+ self.map = ctx.ymap if d.flip else ctx.xmap
291
+ sc = (ctx.y if d.flip else ctx.x).scale
292
+ self.inverse = None
293
+ if d.positions is None or sc is None or sc.function is None:
294
+ self.geo = slots(d, ctx)
295
+ else:
296
+ self.inverse = sc.inverse
297
+ fw, dw = _scale_width(d, ctx, sc)
298
+ self.geo = []
299
+ for p in d.positions:
300
+ fp = sc.f(float(p))
301
+ a, b = _edges(sc, float(p), fw, dw)
302
+ fa, fb = sc.f(a), sc.f(b)
303
+ self.geo.append(((fa + fb) / 2, fb - fa) if dw and math.isfinite(fa) and
304
+ math.isfinite(fb) else (fp, fw))
305
+ off = _offset(d, ctx, sc)
306
+ if off:
307
+ self.geo = [(c + off, w) for c, w in self.geo]
308
+
309
+ def back(self, v: Any) -> Any:
310
+ if self.inverse is None:
311
+ return v
312
+ if isinstance(v, array.array):
313
+ return array.array("d", map(self.inverse, v))
314
+ return [self.inverse(x) for x in v]
315
+
316
+ def px(self, v: float) -> float:
317
+ return self.map.position(v)
318
+
319
+
320
+ def _offset(d: Dodge, ctx: Any, sc: Any) -> float:
321
+ """The series' ``offset`` within the slot, in slot space (0 without one)."""
322
+ from .base import slot_parts, slot_width
323
+ s = d.series
324
+ if "offset" not in s.SETTINGS or s.get("offset") in (None, 0):
325
+ return 0.0
326
+ px = abs((ctx.ymap if d.flip else ctx.xmap).a) or None
327
+ if d.positions is None or sc is None or sc.function is None:
328
+ return slot_width(s, d.positions, d.time, px, "offset")
329
+ return slot_parts(s, d.positions, d.time, sc, px, "offset")[0]
330
+
331
+
332
+ def placed(d: Dodge, ctx: Any) -> Placed:
333
+ """The :class:`Placed` slots of ``d`` for ``ctx`` (once per drawing context)."""
334
+ from .paint import memo
335
+ return memo(ctx, (id(d), "placed"), lambda: Placed(d, ctx))
336
+
337
+
338
+ def _scale_width(d: Dodge, ctx: Any, sc: Any) -> tuple[float, float]:
339
+ """The slot width of ``d``'s series at its positions in the space of scale ``sc`` and
340
+ in data units (:func:`~plot_io.layers.base.slot_parts`, ``max_width`` applied)."""
341
+ from .base import slot_parts
342
+ s = d.series
343
+ px = abs((ctx.ymap if d.flip else ctx.xmap).a) or None
344
+ fw, dw = slot_parts(s, d.positions, d.time, sc, px)
345
+ if "max_width" in s.SETTINGS and s.get("max_width") is not None:
346
+ cf, cd = slot_parts(s, d.positions, d.time, sc, px, "max_width")
347
+ fw, dw = (min(fw, cf) if cf else fw), (min(dw, cd) if cd else dw)
348
+ return fw, dw
349
+
350
+
351
+ def slot_edges(d: Dodge, ctx: Any) -> list[tuple[float, float]]:
352
+ """``(x0, x1)`` of each of ``d.cats`` (as :func:`slots`). At numeric positions on an
353
+ axis with a scale function, the slot and the ``%`` and px parts of the width are in the
354
+ scale's space (edges at ``f⁻¹(f(p) ± w/2)``); a width in data units stays in data
355
+ units (ends outside the scale's domain are drawn at the axes' edge)."""
356
+ sc = (ctx.y if d.flip else ctx.x).scale
357
+ if d.positions is None or sc is None or sc.function is None:
358
+ return [(c - w / 2, c + w / 2) for c, w in slots(d, ctx)]
359
+ fw, dw = _scale_width(d, ctx, sc)
360
+ return [_edges(sc, float(p), fw, dw) for p in d.positions]
361
+
362
+
363
+ def _edges(sc: Any, p: float, fw: float, dw: float) -> tuple[float, float]:
364
+ """Edges of a slot of ``fw`` in the scale's space and ``dw`` in data units at ``p``."""
365
+ fp = sc.f(p)
366
+ a = b = p
367
+ if fw and math.isfinite(fp):
368
+ a, b = sc.inverse(fp - fw / 2), sc.inverse(fp + fw / 2)
369
+ return a - dw / 2, b + dw / 2
370
+
371
+
372
+ def slot_extent(s: Any, positions: Any, time: bool, scale: Any) -> tuple[int, float, float]:
373
+ """``(count, lo, hi)`` of numeric ``positions`` widened by half a slot (``width`` of
374
+ ``s``), for the limits: on an axis with a scale function as :func:`slot_edges` does,
375
+ without the parts outside the scale's domain."""
376
+ from .. import kernels
377
+ from .base import slot_parts, slot_width
378
+ n, lo, hi = kernels.extent(positions, scale.function)
379
+ if scale.function is None:
380
+ w = slot_width(s, positions, time) / 2
381
+ return n, lo - w, hi + w
382
+ if not n:
383
+ return n, lo, hi
384
+ fw, dw = slot_parts(s, positions, time, scale)
385
+ a, b = _edges(scale, lo, fw, dw)[0], _edges(scale, hi, fw, dw)[1]
386
+ return n, (a if scale.contains(a) else lo), (b if scale.contains(b) else hi)
387
+
388
+
389
+ def jitter(n: int, seed: Any) -> list[float]:
390
+ """``n`` uniform draws in [-1, 1] from ``seed``."""
391
+ rng = random.Random(seed)
392
+ return [rng.uniform(-1.0, 1.0) for _ in range(n)]
393
+
394
+
395
+ #: Most points of one swarm series; above, its points are jittered across the width, as a
396
+ #: strip, with a warning.
397
+ SWARM_MAX = 100_000
398
+
399
+
400
+ def swarm_offsets(ctx: Any, d: Dodge, data: list[Any], size: Any,
401
+ widths: list[float]) -> Any:
402
+ """Offsets (category units) that put each category's points side by side without
403
+ overlaps; points that do not fit go to the slot's edge, with a warning. Above
404
+ :data:`SWARM_MAX` points, uniform jitter over the width (seed 0), with a warning."""
405
+ import array
406
+ from .. import kernels
407
+ total = sum(len(v) for v in data)
408
+ if total > SWARM_MAX:
409
+ warnings.warn(f"swarm of {total:,} points (more than {SWARM_MAX:,}): drawn as a "
410
+ "strip, jittered across the width", stacklevel=2)
411
+ r = jitter(total, 0)
412
+ k_of = [k for k, v in enumerate(data) for _ in range(len(v))]
413
+ return array.array("d", (u * widths[k] / 2 for u, k in zip(r, k_of)))
414
+ cmap, vmap = (ctx.ymap, ctx.xmap) if d.flip else (ctx.xmap, ctx.ymap)
415
+ unit = abs(cmap.position(1.0) - cmap.position(0.0)) or 1.0 # page units per slot unit
416
+ out = array.array("d")
417
+ gutter = 0
418
+ start = 0
419
+ for k, v in enumerate(data):
420
+ n = len(v)
421
+ sz = size if isinstance(size, float) else array.array("d", size[start:start + n])
422
+ start += n
423
+ pos = vmap.many(v)
424
+ offs, g = kernels.swarm(pos, sz, widths[k] * unit)
425
+ gutter += g
426
+ sign = 1.0 if cmap.position(1.0) > cmap.position(0.0) else -1.0
427
+ out.extend(o / unit * sign for o in offs)
428
+ if gutter:
429
+ warnings.warn(f"{gutter} swarm points did not fit their slot and were placed at "
430
+ "its edge", stacklevel=2)
431
+ return out