plot-io 0.12.0__py3-none-win_amd64.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 +73 -0
- plot_io/_methods.py +1691 -0
- plot_io/_native/__init__.py +199 -0
- plot_io/_native/__main__.py +197 -0
- plot_io/_native/_api.py +457 -0
- plot_io/_native/bin/windows-x86_64.dll +0 -0
- plot_io/_native/kernels.py +335 -0
- plot_io/_native/manifest.json +49 -0
- plot_io/axis.py +1347 -0
- plot_io/boxes.py +1156 -0
- plot_io/canvas.py +389 -0
- plot_io/color/__init__.py +3 -0
- plot_io/color/data.py +1646 -0
- plot_io/color/maps.py +922 -0
- plot_io/color/values.py +295 -0
- plot_io/color/variants.py +1246 -0
- plot_io/colorbar.py +1426 -0
- plot_io/columns.py +614 -0
- plot_io/decorations.py +920 -0
- plot_io/defaults.py +809 -0
- plot_io/density.py +113 -0
- plot_io/ext.py +68 -0
- plot_io/facets.py +240 -0
- plot_io/figure.py +788 -0
- plot_io/formatters.py +97 -0
- plot_io/genome/__init__.py +17 -0
- plot_io/genome/adapters.py +356 -0
- plot_io/genome/combine.py +123 -0
- plot_io/genome/contacts.py +508 -0
- plot_io/genome/intervals.py +750 -0
- plot_io/genome/lanes.py +191 -0
- plot_io/genome/layout.py +184 -0
- plot_io/genome/links.py +364 -0
- plot_io/genome/pack.py +27 -0
- plot_io/genome/readers.py +145 -0
- plot_io/genome/regions.py +228 -0
- plot_io/genome/scale.py +349 -0
- plot_io/genome/series.py +344 -0
- plot_io/genome/signal.py +621 -0
- plot_io/genome/sites.py +782 -0
- plot_io/genome/sources.py +459 -0
- plot_io/genome/testing.py +161 -0
- plot_io/geometry.py +289 -0
- plot_io/groups.py +308 -0
- plot_io/guides.py +503 -0
- plot_io/helpers/__init__.py +17 -0
- plot_io/helpers/clustermap.py +202 -0
- plot_io/helpers/dendrogram.py +172 -0
- plot_io/helpers/grids.py +304 -0
- plot_io/helpers/qq.py +334 -0
- plot_io/helpers/raincloud.py +249 -0
- plot_io/helpers/tree.py +214 -0
- plot_io/helpers/volcano.py +505 -0
- plot_io/hints.py +125 -0
- plot_io/kernels.py +740 -0
- plot_io/labels.py +566 -0
- plot_io/layers/__init__.py +100 -0
- plot_io/layers/base.py +630 -0
- plot_io/layers/context.py +87 -0
- plot_io/layers/core.py +446 -0
- plot_io/layers/geoms.py +1362 -0
- plot_io/layers/grammar.py +586 -0
- plot_io/layers/paint.py +614 -0
- plot_io/layers/pipeline.py +170 -0
- plot_io/layers/positions.py +431 -0
- plot_io/layers/stats.py +1088 -0
- plot_io/layout.py +1381 -0
- plot_io/legend.py +605 -0
- plot_io/levels.py +509 -0
- plot_io/limits.py +124 -0
- plot_io/lines.py +42 -0
- plot_io/mapped.py +522 -0
- plot_io/markers.py +110 -0
- plot_io/markup.py +128 -0
- plot_io/numbers.py +354 -0
- plot_io/occupancy.py +295 -0
- plot_io/plot.py +597 -0
- plot_io/presets/__init__.py +51 -0
- plot_io/presets/_util.py +153 -0
- plot_io/presets/area.py +145 -0
- plot_io/presets/bar.py +261 -0
- plot_io/presets/box.py +400 -0
- plot_io/presets/catpoints.py +177 -0
- plot_io/presets/contour.py +353 -0
- plot_io/presets/curves.py +30 -0
- plot_io/presets/dots.py +355 -0
- plot_io/presets/ecdf.py +97 -0
- plot_io/presets/errorbar.py +106 -0
- plot_io/presets/errors.py +296 -0
- plot_io/presets/fill.py +74 -0
- plot_io/presets/heatmap.py +322 -0
- plot_io/presets/hexbin.py +198 -0
- plot_io/presets/hist.py +391 -0
- plot_io/presets/hist2d.py +87 -0
- plot_io/presets/kde.py +162 -0
- plot_io/presets/kde2d.py +147 -0
- plot_io/presets/line.py +143 -0
- plot_io/presets/pie.py +502 -0
- plot_io/presets/regression.py +115 -0
- plot_io/presets/scatter.py +194 -0
- plot_io/presets/stem.py +100 -0
- plot_io/presets/violin.py +323 -0
- plot_io/py.typed +0 -0
- plot_io/registry.py +82 -0
- plot_io/render.py +480 -0
- plot_io/rows.py +491 -0
- plot_io/scales.py +103 -0
- plot_io/seams.py +138 -0
- plot_io/settings.py +807 -0
- plot_io/shapes.py +66 -0
- plot_io/sharing.py +232 -0
- plot_io/sizemaps.py +97 -0
- plot_io/spec.py +240 -0
- plot_io/states.py +216 -0
- plot_io/styles/__init__.py +307 -0
- plot_io/styles/library.py +397 -0
- plot_io/tables.py +138 -0
- plot_io/text.py +412 -0
- plot_io/ticks.py +400 -0
- plot_io/times.py +287 -0
- plot_io/titles.py +225 -0
- plot_io/transform.py +114 -0
- plot_io/types.py +13604 -0
- plot_io/units.py +768 -0
- plot_io/validators.py +1029 -0
- plot_io/values.py +190 -0
- plot_io-0.12.0.dist-info/METADATA +215 -0
- plot_io-0.12.0.dist-info/RECORD +130 -0
- plot_io-0.12.0.dist-info/WHEEL +4 -0
- 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
|