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.
- 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-arm64.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,194 @@
|
|
|
1
|
+
"""Scatter: markers at points (``Points``), with optional error bars (``Segments``) and
|
|
2
|
+
point labels (``Text``, placed by :func:`plot_io.labels.around`)."""
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .._methods import ScatterMixin
|
|
8
|
+
from .. import groups
|
|
9
|
+
from ..layers.base import column, common
|
|
10
|
+
from ..layers.grammar import Lay, Spec, flipped
|
|
11
|
+
from ..layers.paint import density_points, density_stats, order_settings
|
|
12
|
+
from ..settings import Setting, table
|
|
13
|
+
from ..validators import boolean, length
|
|
14
|
+
from ..transform import settings_both as coord_settings
|
|
15
|
+
from .errors import Summarized, error_value
|
|
16
|
+
|
|
17
|
+
__all__ = ["Scatter"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Scatter(ScatterMixin, Summarized):
|
|
21
|
+
"""Markers at points ``(x, y)``.
|
|
22
|
+
|
|
23
|
+
``color``, ``edge_color``, ``size``, ``marker`` and ``opacity`` take one value or one per
|
|
24
|
+
point. Numbers per point in ``color``/``edge_color`` are mapped through ``color_map``;
|
|
25
|
+
in ``size``, through ``size_map`` when it is set; ``"density"`` maps the points'
|
|
26
|
+
density. ``orientation="horizontal"`` draws ``x`` on the y axis.
|
|
27
|
+
|
|
28
|
+
``size`` is a diameter; with ``size_basis="area"`` every shape has the area of the circle
|
|
29
|
+
of that diameter (a square of size 4 is drawn 3.54 px wide). See
|
|
30
|
+
`values in color and size <docs/api.md#values-in-color-and-size>`_.
|
|
31
|
+
|
|
32
|
+
Error bars: ``x_error``/``y_error`` take a value, a sequence, ``(low, high)`` sequences
|
|
33
|
+
or ``Errors(values, color=None, line_width=None, cap_length=None)`` (cap: the series'
|
|
34
|
+
``cap_length``, else 4 px). With replicates, ``estimate`` draws a point per ``x`` and
|
|
35
|
+
``interval`` its error bars (`summaries <docs/api.md#summaries-estimate-interval>`_).
|
|
36
|
+
|
|
37
|
+
Point labels (``labels``) go beside their point (8 places, at ``0.25em``) or further out
|
|
38
|
+
on rings up to ``label_reach``, with a leader line to the point; a label meets no other
|
|
39
|
+
label, no point of the series, no leader and no inside legend placed before it, and
|
|
40
|
+
stays inside the axes. Labels are placed in the order given; one without room is
|
|
41
|
+
dropped (``series.labels_dropped`` counts them). The placement is deterministic.
|
|
42
|
+
|
|
43
|
+
Example::
|
|
44
|
+
|
|
45
|
+
rng = random.Random(1)
|
|
46
|
+
p = plt.Plot(width=240, height=160)
|
|
47
|
+
p.scatter([rng.gauss(0, 1) for _ in range(100)], [rng.gauss(0, 1) for _ in range(100)])
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
SETTINGS = table(
|
|
51
|
+
**coord_settings(),
|
|
52
|
+
**groups.Marker.embed(),
|
|
53
|
+
**common("color_map", "size_map", "size_key"),
|
|
54
|
+
**order_settings(),
|
|
55
|
+
x_error=Setting(error_value, doc="Horizontal error bars: a value, a sequence, "
|
|
56
|
+
"`(low, high)` or `Errors`."),
|
|
57
|
+
y_error=Setting(error_value, doc="Vertical error bars."),
|
|
58
|
+
**common("estimate"),
|
|
59
|
+
orientation=Setting(lambda v: _orientation(v),
|
|
60
|
+
doc="`vertical` (x along the x axis) or `horizontal` (x along "
|
|
61
|
+
"the y axis)."),
|
|
62
|
+
labels=Setting(lambda v: _labels(v),
|
|
63
|
+
doc="A text per point (a sequence or a `data=` column; `None` and "
|
|
64
|
+
"`\"\"`: none), placed beside its point or further out with a "
|
|
65
|
+
"leader, meeting no other label, no point and an inside legend; "
|
|
66
|
+
"a label without room is dropped (`labels_dropped`)."),
|
|
67
|
+
**groups.TextStyle.embed("label_", only=("font_size", "color"),
|
|
68
|
+
docs={"font_size": "Font size of the point labels.",
|
|
69
|
+
"color": "Color of the point labels (unset: the "
|
|
70
|
+
"text color)."}),
|
|
71
|
+
label_reach=Setting(length(lo=0, percent="em", em="label_font_size"),
|
|
72
|
+
doc="Farthest a point label goes from its point."),
|
|
73
|
+
label_leaders=Setting(boolean, doc="A line from each label placed away from its "
|
|
74
|
+
"point to the point."),
|
|
75
|
+
**common("name", "z", "rasterize", "simplify", "font_size", "font_family",
|
|
76
|
+
"text_color"),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
DATA = {"x": "position", "y": "position"}
|
|
80
|
+
x = column("x", "x of each point (categories at their index among their own).")
|
|
81
|
+
y = column("y", "y of each point (as x).")
|
|
82
|
+
|
|
83
|
+
def __init__(self, x: Any, y: Any, plot: Any = None, **settings: Any) -> None:
|
|
84
|
+
super().__init__(plot=plot, **settings)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def stats(self) -> dict[str, Any]:
|
|
88
|
+
"""Derived data: ``{"density": values}`` when the series maps a density."""
|
|
89
|
+
return density_stats(self)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def labels_dropped(self) -> int:
|
|
93
|
+
"""Point labels left out for want of room at the last drawing."""
|
|
94
|
+
return self.__dict__.get("_labels_dropped", 0)
|
|
95
|
+
|
|
96
|
+
def _label_picks(self) -> list[tuple[int, Any]]:
|
|
97
|
+
"""``(point index, text)`` of the labels, in priority order."""
|
|
98
|
+
texts = self.resolve("labels")
|
|
99
|
+
if not texts:
|
|
100
|
+
return []
|
|
101
|
+
return [(i, t if isinstance(t, str) else str(t)) for i, t in enumerate(texts)
|
|
102
|
+
if t is not None and t != "" and not (isinstance(t, float) and t != t)]
|
|
103
|
+
|
|
104
|
+
def density_points(self) -> tuple[Any, Any]:
|
|
105
|
+
if flipped(self):
|
|
106
|
+
return density_points(self, self.y, self.x)
|
|
107
|
+
return density_points(self, self.x, self.y)
|
|
108
|
+
|
|
109
|
+
SPEC = Spec(
|
|
110
|
+
[Lay("errors", ("error_source", {"color": "marker", "opacity": "marker",
|
|
111
|
+
"marker": "@points"})),
|
|
112
|
+
Lay("points", ("marker", {"order": True, "once": True, "n": lambda s: len(s.x)}),
|
|
113
|
+
name="points", radius=lambda s: _radius(s.resolve("size")))],
|
|
114
|
+
key={"fill": "marker", "stroke": "marker", "marker": "@points"},
|
|
115
|
+
zero=("x", "y"), color_sources=True, size_sources=True,
|
|
116
|
+
finish=lambda s, b: point_labels(s, b))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _labels(v: Any) -> Any:
|
|
120
|
+
if v is None:
|
|
121
|
+
return None
|
|
122
|
+
if isinstance(v, (str, bytes)) or not hasattr(v, "__len__"):
|
|
123
|
+
raise ValueError(f"expected a text per point (a column of data= by name), got {v!r}")
|
|
124
|
+
return list(v.tolist() if hasattr(v, "tolist") else v)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def point_labels(s: Any, b: Any) -> None:
|
|
128
|
+
"""The text layer of ``s``' point labels (a series of Scatter's settings whose spec has
|
|
129
|
+
a ``points`` layer of its data's points), placed when drawn."""
|
|
130
|
+
from .. import labels
|
|
131
|
+
from ..layers.geoms import GEOMS
|
|
132
|
+
from ..text import font_of
|
|
133
|
+
from ._util import memo, placed_text
|
|
134
|
+
pts = [la for la in b.call.layers if la.geom is GEOMS["points"]]
|
|
135
|
+
if not s.resolve("labels") or not pts:
|
|
136
|
+
return
|
|
137
|
+
layer = pts[-1]
|
|
138
|
+
radius = layer.options.get("radius", 0.0) or 3.0
|
|
139
|
+
|
|
140
|
+
def font(ctx: Any) -> Any:
|
|
141
|
+
c = s.resolve("label_color")
|
|
142
|
+
return font_of(s, s.resolve("label_font_size"),
|
|
143
|
+
color=ctx.color(s.resolve("text_color") if c is None else c))
|
|
144
|
+
|
|
145
|
+
def items(ctx: Any) -> list[tuple[Any, ...]]:
|
|
146
|
+
picks = s._label_picks()
|
|
147
|
+
s.__dict__["_labels_dropped"] = 0
|
|
148
|
+
if not picks:
|
|
149
|
+
return []
|
|
150
|
+
lay = layer.columns(ctx)
|
|
151
|
+
xs, ys = GEOMS["points"]._xy(ctx, lay) # type: ignore[attr-defined]
|
|
152
|
+
if isinstance(xs, float):
|
|
153
|
+
xs, ys = [xs], [ys]
|
|
154
|
+
f = font(ctx)
|
|
155
|
+
sizes, shift = [], []
|
|
156
|
+
for _, t in picks:
|
|
157
|
+
q = labels.quad(ctx.canvas, t, f, 0.0, 0.0, "center", "center")
|
|
158
|
+
us, vs = [c[0] for c in q], [c[1] for c in q] # type: ignore[union-attr]
|
|
159
|
+
sizes.append((max(us) - min(us), max(vs) - min(vs)))
|
|
160
|
+
shift.append(((max(us) + min(us)) / 2, (max(vs) + min(vs)) / 2))
|
|
161
|
+
taken = memo(ctx, "point_labels", lambda: list(ctx.root.avoid))
|
|
162
|
+
n = len(xs)
|
|
163
|
+
keep = [(k, i) for k, (i, _) in enumerate(picks) if i < n]
|
|
164
|
+
got = labels.around((xs, ys), radius, [i for _, i in keep], [sizes[k] for k, _ in keep],
|
|
165
|
+
ctx.rect, labels.PAD * f.size, s.resolve("label_reach"), taken)
|
|
166
|
+
leaders = s.resolve("label_leaders")
|
|
167
|
+
out: list[tuple[Any, ...]] = []
|
|
168
|
+
for (k, i), g in zip(keep, got):
|
|
169
|
+
if g is None:
|
|
170
|
+
continue
|
|
171
|
+
cx, cy, ring = g
|
|
172
|
+
line = None
|
|
173
|
+
if ring and leaders:
|
|
174
|
+
w, h = sizes[k]
|
|
175
|
+
px, py = float(xs[i]), float(ys[i])
|
|
176
|
+
qx = min(max(px, cx - w / 2), cx + w / 2)
|
|
177
|
+
qy = min(max(py, cy - h / 2), cy + h / 2)
|
|
178
|
+
dist = math.hypot(qx - px, qy - py) or 1.0
|
|
179
|
+
line = (px + (qx - px) * radius / dist, py + (qy - py) * radius / dist, qx, qy)
|
|
180
|
+
out.append((picks[k][1], cx - shift[k][0], cy - shift[k][1], f.color, "center",
|
|
181
|
+
"center", None, None, line))
|
|
182
|
+
s.__dict__["_labels_dropped"] = len(picks) - len(out)
|
|
183
|
+
return out
|
|
184
|
+
|
|
185
|
+
b.call.layers.append(placed_text(s, (id(s), "point labels"), items, font))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _radius(size: Any) -> float:
|
|
189
|
+
return size / 2 if isinstance(size, (int, float)) else 0.0
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _orientation(v: Any) -> Any:
|
|
193
|
+
from ..settings import one_of
|
|
194
|
+
return one_of("vertical", "horizontal")(v)
|
plot_io/presets/stem.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Stems: segments from a baseline to each value (``Segments``), ending in a marker
|
|
2
|
+
(``Points``), over a baseline (``Path``): lollipops."""
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .._methods import StemMixin
|
|
7
|
+
from .. import groups
|
|
8
|
+
from .. import kernels
|
|
9
|
+
from ..color.values import color
|
|
10
|
+
from ..layers.base import COMMON, column, common
|
|
11
|
+
from ..layers.core import Table
|
|
12
|
+
from ..layers.grammar import Lay, Spec
|
|
13
|
+
from ..layers.paint import xy_density_points
|
|
14
|
+
from ..settings import Setting, number, one_of, table
|
|
15
|
+
from ..transform import settings_both as coord_settings
|
|
16
|
+
from ..validators import length
|
|
17
|
+
from .errors import Summarized
|
|
18
|
+
|
|
19
|
+
__all__ = ["Stem"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Stem(StemMixin, Summarized):
|
|
23
|
+
"""Stems from ``baseline`` to ``y`` at ``x`` (or at 0, 1, … when ``y`` is omitted);
|
|
24
|
+
numbers per point in ``marker_color`` map through the series' color axis.
|
|
25
|
+
|
|
26
|
+
Stems from ``baseline`` to each value with a marker (lollipops), and a baseline line.
|
|
27
|
+
With replicates, ``estimate`` draws a stem per ``x``.
|
|
28
|
+
|
|
29
|
+
Example::
|
|
30
|
+
|
|
31
|
+
import math
|
|
32
|
+
p = plt.Plot(width=240, height=160)
|
|
33
|
+
p.stem(range(16), [math.sin(i / 2) for i in range(16)])
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
SETTINGS = table(
|
|
37
|
+
**coord_settings(),
|
|
38
|
+
**groups.Marker.embed("marker_"),
|
|
39
|
+
**common("color_map"),
|
|
40
|
+
color=Setting(color, doc="Stem and marker color; default: the plot's next color."),
|
|
41
|
+
line_width=Setting(length(lo=0), inherited=True, doc="Stem width."),
|
|
42
|
+
baseline=Setting(number(), doc="Value the stems start from."),
|
|
43
|
+
**groups.Stroke.embed("baseline_",
|
|
44
|
+
only=("color", "line_width"),
|
|
45
|
+
docs={"color": "Baseline color; `none`: no baseline.",
|
|
46
|
+
"line_width": "Baseline width."}),
|
|
47
|
+
orientation=Setting(one_of("vertical", "horizontal"),
|
|
48
|
+
doc="`vertical` stems (values on y) or `horizontal`."),
|
|
49
|
+
estimate=COMMON["estimate"](),
|
|
50
|
+
**common("name", "opacity", "z", "rasterize", "simplify", "font_size", "font_family",
|
|
51
|
+
"text_color"),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
DATA = {"x": "position", "y": "numbers"}
|
|
55
|
+
x = column("x", "x of each point (categories at their index among their own).")
|
|
56
|
+
y = column("y", "y of each point (as x).")
|
|
57
|
+
|
|
58
|
+
def __init__(self, x: Any, y: Any = None, plot: Any = None, **settings: Any) -> None:
|
|
59
|
+
super().__init__(plot=plot, **settings)
|
|
60
|
+
|
|
61
|
+
def density_points(self) -> tuple[Any, Any]:
|
|
62
|
+
"""The markers' positions on the plot's axes (a density in ``marker_color``)."""
|
|
63
|
+
return xy_density_points(self, self.x, self.y)
|
|
64
|
+
|
|
65
|
+
SPEC = Spec(
|
|
66
|
+
[Lay("path", ("stroke", {"color": "baseline_color", "width": "baseline_width"}),
|
|
67
|
+
table="baseline", key=False, occupy=False),
|
|
68
|
+
Lay("segments", ("stroke", {"color": "faded", "drop_zero": True,
|
|
69
|
+
"extra": ("simplify",)}),
|
|
70
|
+
table="stems", glyph="stem"),
|
|
71
|
+
Lay("points", ("marker", {"prefix": "marker_", "color": "faded",
|
|
72
|
+
"n": lambda s: len(s.x)}),
|
|
73
|
+
occupy=False, glyph_at=0.35, glyph_max=0.7)],
|
|
74
|
+
tables={"stems": lambda s, b: _stems(s, b), "baseline": lambda s, b: _baseline(s, b)},
|
|
75
|
+
key={"fill": "faded"}, zero=("y",), color_sources=True)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _stems(s: Stem, b: Any) -> Table:
|
|
79
|
+
t = Table(x0=s.x, x1=s.x, y0=s.resolve("baseline"), y1=s.y)
|
|
80
|
+
if "x" in b.at:
|
|
81
|
+
t.lazy["x0"] = t.lazy["x1"] = b.at["x"]
|
|
82
|
+
return t
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _baseline(s: Stem, b: Any) -> Table | None:
|
|
86
|
+
"""The baseline under the stems, from the first to the last."""
|
|
87
|
+
if not len(s.x):
|
|
88
|
+
return None
|
|
89
|
+
cnt, lo, hi = kernels.extent(s.x, None)
|
|
90
|
+
if not cnt:
|
|
91
|
+
return None
|
|
92
|
+
base = s.resolve("baseline")
|
|
93
|
+
t = Table(x=[lo, hi], y=[base, base])
|
|
94
|
+
at = b.at.get("x")
|
|
95
|
+
if at is not None:
|
|
96
|
+
def ends(ctx: Any) -> list[float]:
|
|
97
|
+
_, a, b = kernels.extent(at(ctx), None)
|
|
98
|
+
return [a, b]
|
|
99
|
+
t.lazy["x"] = ends
|
|
100
|
+
return t
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Violin plots (stat ``violin``): a mirrored density per category (``Band``, along the
|
|
2
|
+
value axis), with an inner box (``Path``, ``Rects``, ``Points``), quartile lines
|
|
3
|
+
(``Path``) or points (``Points``)."""
|
|
4
|
+
|
|
5
|
+
import array
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .._methods import ViolinMixin
|
|
9
|
+
from .. import columns, groups
|
|
10
|
+
from ..color.values import desaturate, with_alpha
|
|
11
|
+
from ..layers import stats
|
|
12
|
+
from ..layers.base import Series, common
|
|
13
|
+
from ..layers.core import Layer, Table
|
|
14
|
+
from ..layers.geoms import GEOMS, Marks
|
|
15
|
+
from ..layers.grammar import Lay, Spec, axis_of
|
|
16
|
+
from ..layers.paint import Paint, role
|
|
17
|
+
from ..layers.positions import Dodge, placed
|
|
18
|
+
from ..lines import dash_of
|
|
19
|
+
from ..settings import Setting, boolean, either, literal, number, one_of, table
|
|
20
|
+
from ..transform import settings_both as coord_settings
|
|
21
|
+
from ..validators import length
|
|
22
|
+
from ..values import category_paint
|
|
23
|
+
from ..layers.paint import base_colors
|
|
24
|
+
from ._util import fill_colors
|
|
25
|
+
from .kde import check_bandwidth
|
|
26
|
+
|
|
27
|
+
__all__ = ["Violin"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _inner(v: Any) -> Any:
|
|
31
|
+
if v is False or v == "none":
|
|
32
|
+
return False
|
|
33
|
+
return one_of("box", "quartiles", "points")(v)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Violin(ViolinMixin, Series):
|
|
37
|
+
"""Violins of ``values``: a dict ``{category: values}``, a list of arrays (categories
|
|
38
|
+
``0, 1, …`` or ``categories=``), or one array.
|
|
39
|
+
|
|
40
|
+
``values`` as :class:`Box` takes them. Each violin is the density of its category,
|
|
41
|
+
mirrored, in the value axis' space (log axes: in log space); ``width_scale`` gives equal
|
|
42
|
+
areas (default), areas proportional to the counts, or equal widths. A category of one
|
|
43
|
+
value (or all equal) is a line at it, with a warning. ``inner`` draws a thin box with
|
|
44
|
+
whiskers and a median dot, dashed quartiles, the points, or nothing (``False``). With
|
|
45
|
+
``split=True``, two violin series share each slot as facing halves. ``side="low"`` or
|
|
46
|
+
``"high"`` draws one half of each violin (toward lower or higher positions), alone in
|
|
47
|
+
its slot.
|
|
48
|
+
|
|
49
|
+
Example::
|
|
50
|
+
|
|
51
|
+
rng = random.Random(1)
|
|
52
|
+
p = plt.Plot(width=240, height=160)
|
|
53
|
+
p.violin({k: [rng.gauss(m, 1) for _ in range(80)] for k, m in (("A", 0), ("B", 1), ("C", 0.5))})
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
SETTINGS = table(
|
|
57
|
+
**coord_settings(),
|
|
58
|
+
color=Setting(category_paint,
|
|
59
|
+
doc="Fill: a color, one per violin (list or dict by category), or "
|
|
60
|
+
"numbers per violin mapped through `color_map`; default the next "
|
|
61
|
+
"plot's color."),
|
|
62
|
+
**common("color_map"),
|
|
63
|
+
**groups.Stroke.embed("edge_",
|
|
64
|
+
only=("color", "line_width"),
|
|
65
|
+
checks={"color": groups.series_color},
|
|
66
|
+
docs={"color": "Outline and inner color: a color or "
|
|
67
|
+
"`\"series\"` (each violin's own color, at "
|
|
68
|
+
"`opacity`).",
|
|
69
|
+
"line_width": "Outline width."}),
|
|
70
|
+
saturation=Setting(number(0, 1), doc="Saturation of the fills."),
|
|
71
|
+
**groups.FillStyle.embed(docs={"fill": "Fill: `True` (each violin's color), a color, or "
|
|
72
|
+
"`False`.",
|
|
73
|
+
"fill_opacity": "Opacity of the fill, times `opacity` "
|
|
74
|
+
"(outlines keep `opacity`)."}),
|
|
75
|
+
max_width=Setting(length(positive=True, data=True, percent="slot"),
|
|
76
|
+
doc="Largest width of the series' part of a slot (as `width`); "
|
|
77
|
+
"default none."),
|
|
78
|
+
width=Setting(length(positive=True, data=True, percent="slot"),
|
|
79
|
+
doc="Violin width: `%` of the slot, a length (bare numbers: px), `d` "
|
|
80
|
+
"data units, or a duration on time axes."),
|
|
81
|
+
bandwidth=Setting(check_bandwidth, doc="`scott`, `silverman` or a number."),
|
|
82
|
+
weights=Setting(columns.weights,
|
|
83
|
+
doc="A weight per value, counted as repeated values (a dict or a list "
|
|
84
|
+
"per category, as the values; long data: a weight per row)."),
|
|
85
|
+
adjust=Setting(number(positive=True), doc="Factor applied to the bandwidth."),
|
|
86
|
+
cut=Setting(number(lo=0), doc="The density extends this many bandwidths past "
|
|
87
|
+
"the data."),
|
|
88
|
+
points=Setting(number(lo=16, hi=65536, integer=True),
|
|
89
|
+
doc="Points per violin outline."),
|
|
90
|
+
width_scale=Setting(one_of("area", "count", "width"),
|
|
91
|
+
doc="`area`: equal areas; `count`: area proportional to the "
|
|
92
|
+
"number of values; `width`: equal widths."),
|
|
93
|
+
inner=Setting(_inner,
|
|
94
|
+
doc="Inside each violin: `box` (a thin box, whiskers and a median dot), "
|
|
95
|
+
"`quartiles` (dashed lines), `points`, or `False` (nothing)."),
|
|
96
|
+
split=Setting(boolean, doc="Two violin series share each slot as facing "
|
|
97
|
+
"halves."),
|
|
98
|
+
side=Setting(one_of("both", "low", "high"),
|
|
99
|
+
doc="`both` halves, or only the `low` or `high` one (toward lower or "
|
|
100
|
+
"higher positions)."),
|
|
101
|
+
orientation=Setting(one_of("vertical", "horizontal"),
|
|
102
|
+
doc="`vertical` (categories on x) or `horizontal`."),
|
|
103
|
+
dodge=Setting(boolean, doc="Violin and box series sharing a category share its "
|
|
104
|
+
"slot side by side; `False` overlays them."),
|
|
105
|
+
gap=Setting(length(lo=0, percent="slot_part"), doc="Fraction of each part of a shared slot left "
|
|
106
|
+
"empty."),
|
|
107
|
+
categories=Setting(either(literal(None), lambda v: list(v)),
|
|
108
|
+
doc="Category names of a list of arrays."),
|
|
109
|
+
**common("name", "opacity", "z", "rasterize", "simplify", "font_size", "font_family",
|
|
110
|
+
"text_color"),
|
|
111
|
+
)
|
|
112
|
+
DATA = {"values": "categories", "positions": "slots"}
|
|
113
|
+
ITEMS = "violins"
|
|
114
|
+
|
|
115
|
+
def __init__(self, values: Any, positions: Any = None, plot: Any = None,
|
|
116
|
+
**settings: Any) -> None:
|
|
117
|
+
super().__init__(plot=plot, **settings)
|
|
118
|
+
|
|
119
|
+
def value_scale(self) -> Any:
|
|
120
|
+
"""The scale of the value axis (densities are computed in its space)."""
|
|
121
|
+
return stats.axis_scale(axis_of(self, "y"))
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def stats(self) -> dict[Any, dict[str, Any]]:
|
|
125
|
+
"""``{category: {"grid", "density", "count", "box": BoxStats, "bandwidth",
|
|
126
|
+
"line"}}``: densities per unit of the value axis' scale; ``line``: a category
|
|
127
|
+
without a bandwidth, drawn as a line at its value."""
|
|
128
|
+
return stats.run(self, "violin")
|
|
129
|
+
|
|
130
|
+
# -- paint -----------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def _colors(self, ctx: Any) -> list[Any]:
|
|
133
|
+
return base_colors(self, ctx, self.cols.categories, "violins", items=True,
|
|
134
|
+
saturation=False)
|
|
135
|
+
|
|
136
|
+
def fills(self, ctx: Any) -> list[Any]:
|
|
137
|
+
return fill_colors(self, ctx, self._colors(ctx))
|
|
138
|
+
|
|
139
|
+
def edges(self, ctx: Any) -> list[Any]:
|
|
140
|
+
"""Outline and inner color of each violin (``None``: none)."""
|
|
141
|
+
v = self.resolve("edge_color")
|
|
142
|
+
if v != "series":
|
|
143
|
+
return [ctx.color(v)] * len(self.cols.categories)
|
|
144
|
+
sat, a = self.resolve("saturation"), self.resolve("opacity")
|
|
145
|
+
return [with_alpha(desaturate(x, sat) if sat != 1.0 else x, a) if x is not None else None
|
|
146
|
+
for x in self._colors(ctx)]
|
|
147
|
+
|
|
148
|
+
def _half_widths(self) -> dict[Any, float]:
|
|
149
|
+
"""Scale factor per category: half-width in category units per unit of density."""
|
|
150
|
+
st = self.stats
|
|
151
|
+
mode = self.resolve("width_scale")
|
|
152
|
+
peaks = {k: (max(s["density"]) if len(s["density"]) else 0.0) for k, s in st.items()
|
|
153
|
+
if not s["line"]}
|
|
154
|
+
lines = {k: 1.0 for k, s in st.items() if s["line"]} # lines: the full width
|
|
155
|
+
if mode == "width":
|
|
156
|
+
return {**{k: (1.0 / p if p else 0.0) for k, p in peaks.items()}, **lines}
|
|
157
|
+
if mode == "count":
|
|
158
|
+
top = max((peaks[k] * st[k]["count"] for k in peaks), default=0.0)
|
|
159
|
+
return {**{k: (st[k]["count"] / top if top else 0.0) for k in peaks}, **lines}
|
|
160
|
+
top = max(peaks.values(), default=0.0)
|
|
161
|
+
return {**{k: (1.0 / top if top else 0.0) for k in peaks}, **lines}
|
|
162
|
+
|
|
163
|
+
# -- the preset --------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
SPEC = Spec(
|
|
166
|
+
[Lay("band", ("violin.band", {"table": "#bodies"}), table="bodies", stat="violin",
|
|
167
|
+
position="dodge", key=False, turn=True),
|
|
168
|
+
Lay("rects", "violin.key", table="key", name="key", drawn=False)],
|
|
169
|
+
tables={"bodies": lambda s, b: _bodies(s, b), "key": lambda s, b: Table()},
|
|
170
|
+
key={"fill": "symbol", "stroke": "symbol", "marker": "@key"},
|
|
171
|
+
titles={"values": "y", "categories": "x"}, slots="positions",
|
|
172
|
+
color_sources="items", finish=lambda s, b: _finish(s, b))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _bodies(s: Violin, b: Any) -> Table | None:
|
|
176
|
+
"""The violins with values (``drawn``), in slots shared with the plot's dodged boxes
|
|
177
|
+
and violins; ``scale``: half-width per unit of density."""
|
|
178
|
+
st = s.stats
|
|
179
|
+
cats = s.cols.categories
|
|
180
|
+
drawn = [i for i, k in enumerate(cats) if st[k]["count"]]
|
|
181
|
+
if not drawn:
|
|
182
|
+
return None
|
|
183
|
+
scale = s._half_widths()
|
|
184
|
+
d = Dodge(s, "box", b.flip, list(cats), s.cols["positions"],
|
|
185
|
+
"split" if s.resolve("split") else None)
|
|
186
|
+
if not s.resolve("split"):
|
|
187
|
+
d.side = {"low": -1, "high": 1}.get(s.resolve("side"), 0)
|
|
188
|
+
t: Any = Table(x=[st[cats[i]]["grid"] for i in drawn])
|
|
189
|
+
t.dodge, t.drawn, t.scale = d, drawn, scale
|
|
190
|
+
|
|
191
|
+
def halves(ctx: Any, sign: int) -> list[Any]:
|
|
192
|
+
pl = placed(d, ctx)
|
|
193
|
+
out = []
|
|
194
|
+
for i in drawn:
|
|
195
|
+
k = cats[i]
|
|
196
|
+
center, width = pl.geo[i]
|
|
197
|
+
half = width / 2
|
|
198
|
+
if sign * d.side < 0: # the other half of a split violin
|
|
199
|
+
out.append(pl.back(array.array("d", [center] * len(st[k]["density"]))))
|
|
200
|
+
else:
|
|
201
|
+
out.append(pl.back(array.array("d", (center + sign * v * scale[k] * half
|
|
202
|
+
for v in st[k]["density"]))))
|
|
203
|
+
return out
|
|
204
|
+
|
|
205
|
+
t.lazy["y1"] = lambda ctx: halves(ctx, 1)
|
|
206
|
+
t.lazy["y2"] = lambda ctx: halves(ctx, -1)
|
|
207
|
+
return t
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@role("violin.band")
|
|
211
|
+
def _band_paint(s: Violin, ctx: Any, glyph: bool, table: Any = None) -> dict[str, Any]:
|
|
212
|
+
fills, lcs = s.fills(ctx), s.edges(ctx)
|
|
213
|
+
p: dict[str, Any] = {"color": [fills[i] for i in table.drawn],
|
|
214
|
+
"simplify": s.resolve("simplify")}
|
|
215
|
+
lw = s.resolve("edge_width")
|
|
216
|
+
if lw:
|
|
217
|
+
p["stroke"] = [lcs[i] for i in table.drawn]
|
|
218
|
+
p["width"] = lw
|
|
219
|
+
return p
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@role("violin.key")
|
|
223
|
+
def _key_paint(s: Violin, ctx: Any, glyph: bool) -> dict[str, Any]:
|
|
224
|
+
f, e = s.fills(ctx), s.edges(ctx)
|
|
225
|
+
lw = s.resolve("edge_width")
|
|
226
|
+
return {"fill": f[0] if f else None, "stroke": e[0] if e else None,
|
|
227
|
+
"line_width": lw if lw else None}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _finish(s: Violin, b: Any) -> None:
|
|
231
|
+
"""Dates on the category axis; the inner marks of each violin."""
|
|
232
|
+
t = b.tables.get("bodies")
|
|
233
|
+
if t is None:
|
|
234
|
+
return
|
|
235
|
+
t.dodge.time = b.role("x") in b.call.times
|
|
236
|
+
inner = s.resolve("inner")
|
|
237
|
+
if inner:
|
|
238
|
+
for i in t.drawn:
|
|
239
|
+
b.call.layers += _Inner(s, i, b.flip, t).layers(inner)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class _Inner:
|
|
243
|
+
"""The inner marks of violin ``i`` (layers in the violin's frame)."""
|
|
244
|
+
|
|
245
|
+
def __init__(self, v: Violin, i: int, flip: bool, bodies: Any) -> None:
|
|
246
|
+
self.v, self.i, self.flip, self.d = v, i, flip, bodies.dodge
|
|
247
|
+
self.scale, self.lw = bodies.scale, v.resolve("edge_width")
|
|
248
|
+
self.k = v.cols.categories[i]
|
|
249
|
+
self.st = v.stats[self.k]
|
|
250
|
+
|
|
251
|
+
def centre(self, ctx: Any) -> tuple[float, float]:
|
|
252
|
+
"""The violin's centre (a split half's inner marks shift to its side) and width,
|
|
253
|
+
in slot space."""
|
|
254
|
+
c, w = placed(self.d, ctx).geo[self.i]
|
|
255
|
+
return c + (0.0 if not self.d.side else self.d.side * w * 0.06), w
|
|
256
|
+
|
|
257
|
+
def at(self, ctx: Any, xs: list[float]) -> Any:
|
|
258
|
+
"""Slot-space values in data units."""
|
|
259
|
+
return placed(self.d, ctx).back(xs)
|
|
260
|
+
|
|
261
|
+
def _layer(self, geom: str, t: Table, part: str, **options: Any) -> Layer:
|
|
262
|
+
return Layer(GEOMS[geom], t, paint=Paint(self.v, "violin.inner", at=self, part=part,
|
|
263
|
+
**options),
|
|
264
|
+
flip=self.flip, key=False, occupy=False, each=geom == "rects")
|
|
265
|
+
|
|
266
|
+
def layers(self, inner: str) -> list[Layer]:
|
|
267
|
+
b = self.st["box"]
|
|
268
|
+
if inner == "box":
|
|
269
|
+
return self._box(b)
|
|
270
|
+
if inner == "quartiles":
|
|
271
|
+
return [self._quartile(q, "solid" if i == 1 else "dashed")
|
|
272
|
+
for i, q in enumerate((b.q1, b.median, b.q3))]
|
|
273
|
+
pts = Table(y=self.v.cols.values[self.i])
|
|
274
|
+
pts.lazy["x"] = lambda ctx: self.at(ctx, [self.centre(ctx)[0]] *
|
|
275
|
+
len(self.v.cols.values[self.i]))
|
|
276
|
+
return [self._layer("points", pts, "points")]
|
|
277
|
+
|
|
278
|
+
def _box(self, b: Any) -> list[Layer]:
|
|
279
|
+
line = Table(y=[b.low, b.high])
|
|
280
|
+
line.lazy["x"] = lambda ctx: self.at(ctx, [self.centre(ctx)[0]] * 2)
|
|
281
|
+
body = Table(y0=[b.q1], y1=[b.q3])
|
|
282
|
+
body.lazy["x0"] = lambda ctx: self.at(ctx, [self.centre(ctx)[0] -
|
|
283
|
+
self.centre(ctx)[1] * 0.03])
|
|
284
|
+
body.lazy["x1"] = lambda ctx: self.at(ctx, [self.centre(ctx)[0] +
|
|
285
|
+
self.centre(ctx)[1] * 0.03])
|
|
286
|
+
dot = Table(y=[b.median])
|
|
287
|
+
dot.lazy["x"] = lambda ctx: self.at(ctx, [self.centre(ctx)[0]])
|
|
288
|
+
return [self._layer("path", line, "whisker"), self._layer("rects", body, "body"),
|
|
289
|
+
self._layer("points", dot, "median")]
|
|
290
|
+
|
|
291
|
+
def _quartile(self, q: float, dash: str) -> Layer:
|
|
292
|
+
t = Table(y=[q, q])
|
|
293
|
+
side = self.d.side
|
|
294
|
+
|
|
295
|
+
def ends(ctx: Any) -> list[float]:
|
|
296
|
+
c, w = self.centre(ctx)
|
|
297
|
+
dd = stats.density_at(self.st, q, self.v.value_scale()) * self.scale[self.k] * w / 2
|
|
298
|
+
return self.at(ctx, [c - dd if side <= 0 else c, c + dd if side >= 0 else c])
|
|
299
|
+
t.lazy["x"] = ends
|
|
300
|
+
return self._layer("path", t, "quartile", dash=dash)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@role("violin.inner")
|
|
304
|
+
def _inner_paint(s: Violin, ctx: Any, glyph: bool, at: Any = None, part: str = "",
|
|
305
|
+
dash: str = "solid") -> Any:
|
|
306
|
+
"""The inner marks of a violin: its whisker line, body, median dot, quartile lines or
|
|
307
|
+
points, in its outline color."""
|
|
308
|
+
c = s.edges(ctx)[at.i]
|
|
309
|
+
if part == "whisker":
|
|
310
|
+
return {"color": c, "width": max(at.lw, 0.5)}
|
|
311
|
+
if part == "body":
|
|
312
|
+
return {"fill": c, "stroke": None, "line_width": None}
|
|
313
|
+
if part == "quartile":
|
|
314
|
+
width = at.lw or 0.8
|
|
315
|
+
return {"color": c, "width": width, "dash": dash_of(dash, width)}
|
|
316
|
+
if c is None:
|
|
317
|
+
return None
|
|
318
|
+
if part == "points":
|
|
319
|
+
return Marks(None, 3.0, c, None)
|
|
320
|
+
m, w = at.centre(ctx)
|
|
321
|
+
pl = placed(at.d, ctx)
|
|
322
|
+
bw = abs(pl.px(m + w * 0.03) - pl.px(m - w * 0.03))
|
|
323
|
+
return Marks(None, max(bw * 0.9, 2.0), "white", None)
|
plot_io/py.typed
ADDED
|
File without changes
|