plot-io 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plot_io/__init__.py +66 -0
- plot_io/_backend.py +114 -0
- plot_io/axis.py +574 -0
- plot_io/colorbar.py +342 -0
- plot_io/decorations.py +496 -0
- plot_io/defaults.py +766 -0
- plot_io/density.py +97 -0
- plot_io/enums.py +63 -0
- plot_io/facets.py +184 -0
- plot_io/figure.py +331 -0
- plot_io/labels.py +63 -0
- plot_io/legend.py +498 -0
- plot_io/limits.py +92 -0
- plot_io/maps/__init__.py +365 -0
- plot_io/maps/data.py +333 -0
- plot_io/markers.py +117 -0
- plot_io/numbers.py +289 -0
- plot_io/occupancy.py +219 -0
- plot_io/palettes.py +142 -0
- plot_io/plot.py +435 -0
- plot_io/render.py +1122 -0
- plot_io/scales.py +93 -0
- plot_io/series/__init__.py +498 -0
- plot_io/series/area.py +181 -0
- plot_io/series/bar.py +294 -0
- plot_io/series/box.py +401 -0
- plot_io/series/catdata.py +23 -0
- plot_io/series/catpoints.py +182 -0
- plot_io/series/contour.py +294 -0
- plot_io/series/curves.py +128 -0
- plot_io/series/ecdf.py +143 -0
- plot_io/series/errorbar.py +111 -0
- plot_io/series/errors.py +100 -0
- plot_io/series/fill.py +168 -0
- plot_io/series/heatmap.py +304 -0
- plot_io/series/hexbin.py +216 -0
- plot_io/series/hist.py +379 -0
- plot_io/series/hist2d.py +163 -0
- plot_io/series/kde.py +228 -0
- plot_io/series/kde2d.py +153 -0
- plot_io/series/line.py +173 -0
- plot_io/series/pie.py +304 -0
- plot_io/series/points.py +341 -0
- plot_io/series/regression.py +250 -0
- plot_io/series/scatter.py +103 -0
- plot_io/series/stem.py +112 -0
- plot_io/series/violin.py +321 -0
- plot_io/settings.py +281 -0
- plot_io/shapes.py +57 -0
- plot_io/sharing.py +160 -0
- plot_io/slots.py +52 -0
- plot_io/stacking.py +63 -0
- plot_io/stats.py +140 -0
- plot_io/style.py +71 -0
- plot_io/styles/__init__.py +234 -0
- plot_io/styles/library.py +732 -0
- plot_io/tables.py +141 -0
- plot_io/text.py +109 -0
- plot_io/ticks.py +284 -0
- plot_io/times.py +199 -0
- plot_io/values.py +161 -0
- plot_io-0.1.0.dist-info/METADATA +102 -0
- plot_io-0.1.0.dist-info/RECORD +65 -0
- plot_io-0.1.0.dist-info/WHEEL +4 -0
- plot_io-0.1.0.dist-info/licenses/LICENSE +28 -0
plot_io/series/hexbin.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Hexagonal 2-D histograms: counts of points in hexagons, colored by a map."""
|
|
2
|
+
|
|
3
|
+
import array
|
|
4
|
+
import math
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pdf_io
|
|
8
|
+
from pdf_io import arrays as _arrays
|
|
9
|
+
|
|
10
|
+
from .. import _backend
|
|
11
|
+
from ..maps import ColorMap, check_cmap
|
|
12
|
+
from ..limits import check_limits
|
|
13
|
+
from ..scales import NUMERIC_SCALES, get_scale
|
|
14
|
+
from ..settings import Setting, color, either, literal, number, one_of, table
|
|
15
|
+
from ..style import with_alpha
|
|
16
|
+
from . import Series, common
|
|
17
|
+
from .points import map_colors
|
|
18
|
+
|
|
19
|
+
__all__ = ["Hexbin"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_S3 = math.sqrt(3.0)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Hexbin(Series):
|
|
26
|
+
"""A hexagonal 2-D histogram of points ``(x, y)``: ``gridsize`` regular hexagons across
|
|
27
|
+
the axes width. The lattice is laid out in page units once the axes are placed, so the
|
|
28
|
+
hexagons are regular and tile exactly whatever the axes' aspect."""
|
|
29
|
+
|
|
30
|
+
SETTINGS = table(
|
|
31
|
+
gridsize=Setting(number(lo=1, integer=True), doc="Hexagons across the axes width."),
|
|
32
|
+
weights=Setting(lambda v: _backend.doubles(v), doc="A weight per point (sums)."),
|
|
33
|
+
stat=Setting(one_of("count", "probability", "percent"),
|
|
34
|
+
doc="`count`, `probability` or `percent`."),
|
|
35
|
+
min_count=Setting(number(lo=0), doc="Hexagons below this count are not drawn."),
|
|
36
|
+
color_map=Setting(check_cmap, inherited=True, doc="A `ColorMap`, a map name or colors."),
|
|
37
|
+
color_limits=Setting(check_limits, doc="Override of the map's limits."),
|
|
38
|
+
color_scale=Setting(one_of(*NUMERIC_SCALES), doc="Override of the map's scale."),
|
|
39
|
+
line_color=Setting(color, doc="Hexagon outline color; default none."),
|
|
40
|
+
line_width=Setting(number(lo=0), doc="Outline width (pt).", length=True),
|
|
41
|
+
colorbar=Setting(either(literal(False), literal(True)),
|
|
42
|
+
doc="Attach a colorbar to the plot."),
|
|
43
|
+
**common("name", "alpha", "z", "rasterize", "simplify", "font_size", "font_family",
|
|
44
|
+
"text_color"),
|
|
45
|
+
)
|
|
46
|
+
USES_PALETTE = False
|
|
47
|
+
|
|
48
|
+
def __init__(self, x: Any, y: Any, plot: Any = None, **settings: Any) -> None:
|
|
49
|
+
self.px, self.py = _backend.doubles(x), _backend.doubles(y)
|
|
50
|
+
if len(self.px) != len(self.py):
|
|
51
|
+
raise ValueError(f"x has {len(self.px)} values and y {len(self.py)}")
|
|
52
|
+
self._map: Any = None
|
|
53
|
+
self._map_key: Any = None
|
|
54
|
+
self._bar: Any = None
|
|
55
|
+
#: The page frame the lattice is laid out in (set by the layout once the axes are
|
|
56
|
+
#: placed): ``(function_x, a_x, b_x, function_y, a_y, b_y, width, height)``, page
|
|
57
|
+
#: coordinates relative to the axes' top-left corner being ``a·f(v) + b``.
|
|
58
|
+
self._frame: Any = None
|
|
59
|
+
super().__init__(plot=plot, **settings)
|
|
60
|
+
if self.get("colorbar"):
|
|
61
|
+
self._bar = self.plot.colorbar(self)
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def _frame_of(ctx: Any) -> tuple:
|
|
65
|
+
x0, y0, x1, y1 = ctx.rect
|
|
66
|
+
xm, ym = ctx.xmap, ctx.ymap
|
|
67
|
+
return (xm.function, xm.a, xm.b - x0, ym.function, ym.a, ym.b - y0, x1 - x0, y1 - y0)
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _same(f: tuple, g: tuple) -> bool:
|
|
71
|
+
return f[0] == g[0] and f[3] == g[3] and all(
|
|
72
|
+
abs(a - b) <= 1e-6 * max(1.0, abs(a), abs(b))
|
|
73
|
+
for a, b in zip(f[1:3] + f[4:], g[1:3] + g[4:]))
|
|
74
|
+
|
|
75
|
+
def set_frame(self, ctx: Any) -> bool:
|
|
76
|
+
"""Lays the lattice out for the placed axes; whether that changed it."""
|
|
77
|
+
f = self._frame_of(ctx)
|
|
78
|
+
if self._frame is not None and self._same(f, self._frame):
|
|
79
|
+
return False
|
|
80
|
+
self._frame = f
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
def _guess_frame(self) -> tuple:
|
|
84
|
+
"""A frame before the axes are placed: the data range over the nominal plot size."""
|
|
85
|
+
fig = self.plot.figure
|
|
86
|
+
k = fig.points_per_unit
|
|
87
|
+
w = fig.get("plot_width") * k
|
|
88
|
+
h = fig.get("plot_height") * k
|
|
89
|
+
out: list[Any] = []
|
|
90
|
+
for axis, values, (p0, p1) in ((self.plot.x_axis, self.px, (0.0, w)),
|
|
91
|
+
(self.plot.y_axis, self.py, (h, 0.0))):
|
|
92
|
+
name = axis.nominal_scale
|
|
93
|
+
sc = get_scale("linear" if name == "category" else name)
|
|
94
|
+
n, lo, hi = _backend.extent(values, sc.function)
|
|
95
|
+
if not n:
|
|
96
|
+
lo, hi = 0.0, 1.0
|
|
97
|
+
if hi == lo:
|
|
98
|
+
lo, hi = lo - 0.5, hi + 0.5
|
|
99
|
+
frac = axis.get("padding")
|
|
100
|
+
pad = (axis.get("auto_padding") if frac == "auto" else frac) * (hi - lo)
|
|
101
|
+
lo, hi = lo - pad, hi + pad
|
|
102
|
+
a = (p1 - p0) / (hi - lo)
|
|
103
|
+
out += [sc.function, a, p0 - a * lo]
|
|
104
|
+
return (*out, w, h)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def stats(self) -> dict[str, Any]:
|
|
108
|
+
"""``{"x", "y": hexagon centres (page units, relative to the axes' top-left corner),
|
|
109
|
+
"values", "dx": hexagon width (pt)}``."""
|
|
110
|
+
frame = self._frame or self._guess_frame()
|
|
111
|
+
return self.cached(("stats", frame), lambda: self._compute(frame))
|
|
112
|
+
|
|
113
|
+
def _compute(self, frame: tuple) -> dict[str, Any]:
|
|
114
|
+
fx, ax, bx, fy, ay, by, w, h = frame
|
|
115
|
+
nx = self.get("gridsize")
|
|
116
|
+
dx = w / nx
|
|
117
|
+
dy = _S3 * dx
|
|
118
|
+
nan = math.nan
|
|
119
|
+
u = _backend.scaled(self.px, ax, bx, fx)
|
|
120
|
+
v = _backend.scaled(self.py, ay, by, fy)
|
|
121
|
+
# Points outside the axes are not counted.
|
|
122
|
+
u = _backend.scaled(u, 1.0, 0.0, None, low=0.0, high=w, under=nan, over=nan)
|
|
123
|
+
v = _backend.scaled(v, 1.0, 0.0, None, low=0.0, high=h, under=nan, over=nan)
|
|
124
|
+
ny = int(h / dy) + 2
|
|
125
|
+
cx, cy, cs = _arrays.hexbin(u, v, 0.0, 0.0, dx, dy, nx + 1, ny, self.get("weights"))
|
|
126
|
+
total = math.fsum(cs)
|
|
127
|
+
mc = self.get("min_count")
|
|
128
|
+
keep = [i for i in range(len(cs)) if cs[i] >= mc and cs[i] > 0]
|
|
129
|
+
st = self.get("stat")
|
|
130
|
+
k = 1.0 if st == "count" else ((100.0 if st == "percent" else 1.0) / (total or 1.0))
|
|
131
|
+
return {"x": array.array("d", (cx[i] for i in keep)),
|
|
132
|
+
"y": array.array("d", (cy[i] for i in keep)),
|
|
133
|
+
"values": array.array("d", (cs[i] * k for i in keep)), "dx": dx}
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def hexagon(dx: float) -> list[tuple[float, float]]:
|
|
137
|
+
"""Vertices of the regular hexagon (pointy top) of width ``dx`` centred on 0."""
|
|
138
|
+
r = dx / _S3
|
|
139
|
+
return [(0.5 * dx, -0.5 * r), (0.5 * dx, 0.5 * r), (0.0, r), (-0.5 * dx, 0.5 * r),
|
|
140
|
+
(-0.5 * dx, -0.5 * r), (0.0, -r)]
|
|
141
|
+
|
|
142
|
+
def color_map(self) -> ColorMap:
|
|
143
|
+
cmap, lim, sc = self.get("color_map"), self.get("color_limits"), self.get("color_scale")
|
|
144
|
+
key = (id(cmap) if isinstance(cmap, ColorMap) else repr(cmap), lim, sc)
|
|
145
|
+
if self._map is None or self._map_key != key:
|
|
146
|
+
if isinstance(cmap, ColorMap) and lim is None and sc is None:
|
|
147
|
+
self._map = cmap
|
|
148
|
+
else:
|
|
149
|
+
base = cmap if isinstance(cmap, ColorMap) else ColorMap(cmap)
|
|
150
|
+
self._map = ColorMap(base, limits=lim if lim is not None else base.limits,
|
|
151
|
+
scale=sc or base.scale)
|
|
152
|
+
self._map_key = key
|
|
153
|
+
return self._map
|
|
154
|
+
|
|
155
|
+
def color_sources(self) -> list[tuple[Any, Any]]:
|
|
156
|
+
return [(self.color_map(), self.stats["values"])]
|
|
157
|
+
|
|
158
|
+
def has_own_color(self) -> bool:
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
def values(self, role: str) -> Any:
|
|
162
|
+
return self.px if role == "x" else self.py
|
|
163
|
+
|
|
164
|
+
def legend_points(self) -> tuple[Any, Any]:
|
|
165
|
+
return self.px, self.py
|
|
166
|
+
|
|
167
|
+
def occupy(self, ctx: Any, occ: Any) -> None:
|
|
168
|
+
st = self._page_stats(ctx)
|
|
169
|
+
x0, y0 = ctx.rect[0], ctx.rect[1]
|
|
170
|
+
hw, hh = st["dx"] / 2, st["dx"] / _S3
|
|
171
|
+
occ.rects([x0 + x - hw for x in st["x"]], [y0 + y - hh for y in st["y"]],
|
|
172
|
+
[x0 + x + hw for x in st["x"]], [y0 + y + hh for y in st["y"]])
|
|
173
|
+
|
|
174
|
+
def _page_stats(self, ctx: Any) -> dict[str, Any]:
|
|
175
|
+
frame = self._frame_of(ctx)
|
|
176
|
+
if self._frame is not None and self._same(frame, self._frame):
|
|
177
|
+
return self.stats
|
|
178
|
+
# Placed differently than laid out (e.g. one plot saved alone): recount; the colors
|
|
179
|
+
# keep the map limits resolved before.
|
|
180
|
+
return self.cached(("stats", frame), lambda: self._compute(frame))
|
|
181
|
+
|
|
182
|
+
def draw(self, ctx: Any) -> None:
|
|
183
|
+
st = self._page_stats(ctx)
|
|
184
|
+
if not len(st["x"]):
|
|
185
|
+
return
|
|
186
|
+
dx = st["dx"]
|
|
187
|
+
path = pdf_io.Path()
|
|
188
|
+
pts = [(x / dx, y / dx) for x, y in self.hexagon(dx)]
|
|
189
|
+
path.move_to(*pts[0])
|
|
190
|
+
for p in pts[1:]:
|
|
191
|
+
path.line_to(*p)
|
|
192
|
+
path.close()
|
|
193
|
+
cmap = self.color_map()
|
|
194
|
+
fills = map_colors(cmap, st["values"], ctx.maps[id(cmap)])
|
|
195
|
+
a = self.get("alpha")
|
|
196
|
+
lc = ctx.color(self.get("line_color"))
|
|
197
|
+
lw = self.get("line_width")
|
|
198
|
+
x0, y0 = ctx.rect[0], ctx.rect[1]
|
|
199
|
+
# The hexagons are drawn 0.2 % larger than the lattice so that anti-aliased seams
|
|
200
|
+
# between neighbours don't show; the lattice itself tiles exactly.
|
|
201
|
+
ctx.canvas.draw_markers(_backend.scaled(st["x"], 1.0, x0),
|
|
202
|
+
_backend.scaled(st["y"], 1.0, y0), marker=path,
|
|
203
|
+
size=dx * 1.002, fill=fills,
|
|
204
|
+
stroke=lc if lw and lc is not None else None,
|
|
205
|
+
line_width=lw or None, opacity=1.0 if a is None else a,
|
|
206
|
+
simplify=self.simplify_arg())
|
|
207
|
+
|
|
208
|
+
def legend_colors(self, ctx: Any) -> tuple[Any, Any]:
|
|
209
|
+
cmap = self.color_map()
|
|
210
|
+
return with_alpha(cmap(0.6), self.get("alpha")), None
|
|
211
|
+
|
|
212
|
+
def legend_glyph(self, ctx: Any, x: float, y: float, w: float, h: float) -> None:
|
|
213
|
+
fill, _ = self.legend_colors(ctx)
|
|
214
|
+
ctx.canvas.draw_markers([x + w / 2], [y + h / 2], marker="M0.5 -0.29 L0.5 0.29 L0 "
|
|
215
|
+
"0.58 L-0.5 0.29 L-0.5 -0.29 L0 -0.58 Z", size=h * 0.9,
|
|
216
|
+
fill=fill, stroke=None)
|
plot_io/series/hist.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""Histograms: counts (or densities…) of values in bins, as bars or steps."""
|
|
2
|
+
|
|
3
|
+
import array
|
|
4
|
+
import math
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .. import _backend, stats
|
|
8
|
+
from ..labels import bar_labels, check_labels
|
|
9
|
+
from ..numbers import NumberStyle, check_format, format_numbers, number_settings
|
|
10
|
+
from ..scales import get_scale
|
|
11
|
+
from ..settings import Setting, boolean, color, either, literal, number, one_of, pair, table
|
|
12
|
+
from ..shapes import draw_rects, fill_polygons
|
|
13
|
+
from ..stacking import check_stack, stacked
|
|
14
|
+
from ..style import desaturate, with_alpha
|
|
15
|
+
from ..text import Font
|
|
16
|
+
from . import Series, common
|
|
17
|
+
from .points import colors_only
|
|
18
|
+
|
|
19
|
+
__all__ = ["Hist", "value_sets"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def value_sets(values: Any) -> tuple[list[tuple[Any, Any]], bool]:
|
|
23
|
+
"""``([(name, doubles)], named)`` of one array, a dict or a list of arrays."""
|
|
24
|
+
if isinstance(values, dict):
|
|
25
|
+
return [(k, _backend.doubles(v)) for k, v in values.items()], True
|
|
26
|
+
if isinstance(values, (list, tuple)) and values and all(
|
|
27
|
+
hasattr(v, "__len__") and not isinstance(v, str) for v in values):
|
|
28
|
+
return [(i, _backend.doubles(v)) for i, v in enumerate(values)], False
|
|
29
|
+
try:
|
|
30
|
+
mv = memoryview(values)
|
|
31
|
+
if mv.ndim == 2:
|
|
32
|
+
from pdf_io import arrays
|
|
33
|
+
return [(i, r) for i, r in enumerate(arrays.rows(values))], False
|
|
34
|
+
except TypeError:
|
|
35
|
+
pass
|
|
36
|
+
return [(0, _backend.doubles(values))], False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _weights(v: Any) -> Any:
|
|
40
|
+
if isinstance(v, dict):
|
|
41
|
+
return {k: _backend.doubles(x) for k, x in v.items()}
|
|
42
|
+
if isinstance(v, (list, tuple)) and v and all(hasattr(x, "__len__") for x in v):
|
|
43
|
+
return [_backend.doubles(x) for x in v]
|
|
44
|
+
return _backend.doubles(v)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_STATS = ("count", "density", "probability", "percent", "frequency")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Hist(Series):
|
|
51
|
+
"""A histogram of ``values``: one array, or several (a dict or a list of arrays) that
|
|
52
|
+
share the same bins."""
|
|
53
|
+
|
|
54
|
+
SETTINGS = table(
|
|
55
|
+
bins=Setting(stats.check_bins,
|
|
56
|
+
doc="A count, edges, or a rule: `auto`, `fd`, `sturges`, `scott`, `sqrt`, "
|
|
57
|
+
"`rice`, `doane` (numpy's)."),
|
|
58
|
+
range=Setting(either(literal(None), pair(number())), doc="Range of the bins; default "
|
|
59
|
+
"the data range."),
|
|
60
|
+
weights=Setting(_weights, doc="A weight per value (per set for several sets)."),
|
|
61
|
+
stat=Setting(one_of(*_STATS),
|
|
62
|
+
doc="`count`, `density` (area 1), `probability`, `percent` or "
|
|
63
|
+
"`frequency` (count per unit)."),
|
|
64
|
+
cumulative=Setting(boolean, doc="Accumulate the bins."),
|
|
65
|
+
kind=Setting(one_of("bar", "step", "stepfilled"),
|
|
66
|
+
doc="`bar` (adjacent bars), `step` (outline) or `stepfilled`."),
|
|
67
|
+
bins_scale=Setting(one_of("axis", "linear"),
|
|
68
|
+
doc="`axis`: bins even on the axis scale (log axes: in log space); "
|
|
69
|
+
"`linear`: even in data units."),
|
|
70
|
+
orientation=Setting(one_of("vertical", "horizontal"),
|
|
71
|
+
doc="`vertical` (bins along x) or `horizontal`."),
|
|
72
|
+
stack=Setting(check_stack, doc="Stack the sets (and other stacked histograms "
|
|
73
|
+
"with the same bins)."),
|
|
74
|
+
dodge=Setting(boolean, doc="Put the sets side by side within each bin."),
|
|
75
|
+
color=Setting(colors_only, doc="Fill color, or one per set; default palette colors."),
|
|
76
|
+
line_color=Setting(color, doc="Outline color (bars: default none; steps: the fill "
|
|
77
|
+
"color)."),
|
|
78
|
+
line_width=Setting(number(lo=0), doc="Outline width (pt); default 0 for bars, 1 for "
|
|
79
|
+
"steps.", length=True),
|
|
80
|
+
alpha=Setting(number(0, 1), doc="Opacity; default 1, 0.5 for overlaid sets."),
|
|
81
|
+
saturation=Setting(number(0, 1), doc="Saturation of the fills."),
|
|
82
|
+
labels=Setting(check_labels, doc="Value labels on bars."),
|
|
83
|
+
label_format=Setting(check_format, doc="Number format of the labels."),
|
|
84
|
+
**common("name", "z", "rasterize", "simplify", "font_size", "font_family", "text_color"),
|
|
85
|
+
**number_settings(),
|
|
86
|
+
)
|
|
87
|
+
STACK_GROUP = "hist"
|
|
88
|
+
|
|
89
|
+
def __init__(self, values: Any, plot: Any = None, **settings: Any) -> None:
|
|
90
|
+
self.sets, self.named = value_sets(values)
|
|
91
|
+
super().__init__(plot=plot, **settings)
|
|
92
|
+
|
|
93
|
+
def _horizontal(self) -> bool:
|
|
94
|
+
return self.get("orientation") == "horizontal"
|
|
95
|
+
|
|
96
|
+
def _bin_role(self) -> str:
|
|
97
|
+
return "y" if self._horizontal() else "x"
|
|
98
|
+
|
|
99
|
+
def _bin_scale(self) -> Any:
|
|
100
|
+
if self.get("bins_scale") == "linear":
|
|
101
|
+
return get_scale("linear")
|
|
102
|
+
axis = self.plot.y_axis if self._horizontal() else self.plot.x_axis
|
|
103
|
+
name = axis.nominal_scale
|
|
104
|
+
return get_scale("linear" if name == "category" else name)
|
|
105
|
+
|
|
106
|
+
# -- statistics -------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def stats(self) -> dict[str, Any]:
|
|
110
|
+
"""``{"edges": bin edges, "heights": [per set], "counts": [per set]}``."""
|
|
111
|
+
scale = self._bin_scale()
|
|
112
|
+
return self.cached(("stats", scale.name), lambda: self._compute(scale))
|
|
113
|
+
|
|
114
|
+
def _compute(self, scale: Any) -> dict[str, Any]:
|
|
115
|
+
fn = scale.function
|
|
116
|
+
fsets = [_backend.scaled(v, 1.0, 0.0, fn) if fn else v for _, v in self.sets]
|
|
117
|
+
bins = self.get("bins")
|
|
118
|
+
rng = self.get("range")
|
|
119
|
+
frng = None if rng is None else (scale.f(rng[0]), scale.f(rng[1]))
|
|
120
|
+
if isinstance(bins, array.array):
|
|
121
|
+
fedges = array.array("d", (scale.f(e) for e in bins))
|
|
122
|
+
else:
|
|
123
|
+
if len(fsets) == 1:
|
|
124
|
+
allv = fsets[0]
|
|
125
|
+
else:
|
|
126
|
+
allv = array.array("d")
|
|
127
|
+
for v in fsets:
|
|
128
|
+
allv.extend(v)
|
|
129
|
+
fedges = stats.bin_edges(allv, bins, frng)
|
|
130
|
+
edges = array.array("d", (scale.inverse(e) for e in fedges))
|
|
131
|
+
weights = self.get("weights")
|
|
132
|
+
st = self.get("stat")
|
|
133
|
+
counts, heights = [], []
|
|
134
|
+
for i, ((key, _), fv) in enumerate(zip(self.sets, fsets)):
|
|
135
|
+
w = None
|
|
136
|
+
if weights is not None:
|
|
137
|
+
w = (weights[key] if isinstance(weights, dict) else
|
|
138
|
+
weights[i] if isinstance(weights, list) else weights)
|
|
139
|
+
c = stats.histogram(fv, fedges, w)
|
|
140
|
+
counts.append(c)
|
|
141
|
+
total = math.fsum(c)
|
|
142
|
+
widths = [b - a for a, b in zip(edges, edges[1:])]
|
|
143
|
+
if st == "count":
|
|
144
|
+
hgt = list(c)
|
|
145
|
+
elif st == "probability":
|
|
146
|
+
hgt = [x / total if total else 0.0 for x in c]
|
|
147
|
+
elif st == "percent":
|
|
148
|
+
hgt = [100.0 * x / total if total else 0.0 for x in c]
|
|
149
|
+
elif st == "frequency":
|
|
150
|
+
hgt = [x / wd for x, wd in zip(c, widths)]
|
|
151
|
+
else:
|
|
152
|
+
hgt = [x / (total * wd) if total else 0.0 for x, wd in zip(c, widths)]
|
|
153
|
+
if self.get("cumulative"):
|
|
154
|
+
acc, out = 0.0, []
|
|
155
|
+
for x, wd in zip(hgt, widths):
|
|
156
|
+
acc += x * wd if st in ("density", "frequency") else x
|
|
157
|
+
out.append(acc)
|
|
158
|
+
hgt = out
|
|
159
|
+
heights.append(array.array("d", hgt))
|
|
160
|
+
return {"edges": edges, "heights": heights, "counts": counts}
|
|
161
|
+
|
|
162
|
+
def stack_layers(self) -> list[tuple[list[Any], Any]]:
|
|
163
|
+
st = self.stats
|
|
164
|
+
e = st["edges"]
|
|
165
|
+
keys = [(round(a, 12), round(b, 12)) for a, b in zip(e, e[1:])]
|
|
166
|
+
return [(keys, h) for h in st["heights"]]
|
|
167
|
+
|
|
168
|
+
def _ends(self) -> list[tuple[list[float], list[float]]]:
|
|
169
|
+
if self.get("stack"):
|
|
170
|
+
return stacked(self)
|
|
171
|
+
return [([0.0] * len(h), list(h)) for h in self.stats["heights"]]
|
|
172
|
+
|
|
173
|
+
# -- axes -------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
def values(self, role: str) -> Any:
|
|
176
|
+
if role == self._bin_role():
|
|
177
|
+
return self.stats["edges"]
|
|
178
|
+
out = array.array("d", [0.0])
|
|
179
|
+
for b, t in self._ends():
|
|
180
|
+
out.extend(x for x in t if math.isfinite(x))
|
|
181
|
+
out.extend(x for x in b if math.isfinite(x))
|
|
182
|
+
return out
|
|
183
|
+
|
|
184
|
+
def zero_default(self, role: str) -> bool:
|
|
185
|
+
return role != self._bin_role()
|
|
186
|
+
|
|
187
|
+
def palette_count(self) -> int:
|
|
188
|
+
return len(self.sets)
|
|
189
|
+
|
|
190
|
+
def has_own_color(self) -> bool:
|
|
191
|
+
return self.is_set("color")
|
|
192
|
+
|
|
193
|
+
def legend_labels(self) -> list[Any]:
|
|
194
|
+
if self.named:
|
|
195
|
+
return [str(k) for k, _ in self.sets]
|
|
196
|
+
name = self.get("name")
|
|
197
|
+
return [] if name is None else [name]
|
|
198
|
+
|
|
199
|
+
def occupy(self, ctx: Any, occ: Any) -> None:
|
|
200
|
+
e = list(self.stats["edges"])
|
|
201
|
+
step = self.get("kind") == "step"
|
|
202
|
+
horiz = self._horizontal()
|
|
203
|
+
for bottom, top in self._ends():
|
|
204
|
+
if step:
|
|
205
|
+
t, v = [], []
|
|
206
|
+
for a, b, h in zip(e, e[1:], top):
|
|
207
|
+
t += [a, b]
|
|
208
|
+
v += [h, h]
|
|
209
|
+
px, py = (v, t) if horiz else (t, v)
|
|
210
|
+
occ.lines(ctx.xmap.many(px), ctx.ymap.many(py))
|
|
211
|
+
continue
|
|
212
|
+
if horiz:
|
|
213
|
+
occ.rects(ctx.xmap.many(bottom), ctx.ymap.many(e[:-1]), ctx.xmap.many(top),
|
|
214
|
+
ctx.ymap.many(e[1:]))
|
|
215
|
+
else:
|
|
216
|
+
occ.rects(ctx.xmap.many(e[:-1]), ctx.ymap.many(bottom), ctx.xmap.many(e[1:]),
|
|
217
|
+
ctx.ymap.many(top))
|
|
218
|
+
|
|
219
|
+
def legend_points(self) -> tuple[Any, Any] | None:
|
|
220
|
+
st = self.stats
|
|
221
|
+
e = st["edges"]
|
|
222
|
+
mids = [(a + b) / 2 for a, b in zip(e, e[1:])]
|
|
223
|
+
xs, ys = array.array("d"), array.array("d")
|
|
224
|
+
for _, t in self._ends():
|
|
225
|
+
xs.extend(mids)
|
|
226
|
+
ys.extend(t)
|
|
227
|
+
return (ys, xs) if self._horizontal() else (xs, ys)
|
|
228
|
+
|
|
229
|
+
# -- drawing ----------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
def _alpha(self) -> float:
|
|
232
|
+
return self.get("alpha")
|
|
233
|
+
|
|
234
|
+
def set_colors(self, ctx: Any) -> list[Any]:
|
|
235
|
+
c = self.get("color")
|
|
236
|
+
n = len(self.sets)
|
|
237
|
+
if isinstance(c, list):
|
|
238
|
+
if len(c) != n:
|
|
239
|
+
raise ValueError(f"{len(c)} colors for {n} sets")
|
|
240
|
+
cols = [ctx.color(x) for x in c]
|
|
241
|
+
elif c is not None:
|
|
242
|
+
cols = [ctx.color(c)] * n
|
|
243
|
+
else:
|
|
244
|
+
cols = self.auto_colors(ctx)
|
|
245
|
+
sat = self.get("saturation")
|
|
246
|
+
return [desaturate(x, sat) if sat != 1.0 and x is not None else x for x in cols]
|
|
247
|
+
|
|
248
|
+
def draw(self, ctx: Any) -> None:
|
|
249
|
+
st = self.stats
|
|
250
|
+
edges = st["edges"]
|
|
251
|
+
horiz = self._horizontal()
|
|
252
|
+
bmap, vmap = (ctx.ymap, ctx.xmap) if horiz else (ctx.xmap, ctx.ymap)
|
|
253
|
+
kind = self.get("kind")
|
|
254
|
+
cols = self.set_colors(ctx)
|
|
255
|
+
alpha = self._alpha()
|
|
256
|
+
ends = self._ends()
|
|
257
|
+
n = len(self.sets)
|
|
258
|
+
dodge = self.get("dodge") and n > 1
|
|
259
|
+
lc = self.get("line_color")
|
|
260
|
+
if kind == "bar":
|
|
261
|
+
lw = self.get("line_width")
|
|
262
|
+
xs, ys, ws, hs, fills, rects, texts_v = [], [], [], [], [], [], []
|
|
263
|
+
for k, ((bots, tops), col) in enumerate(zip(ends, cols)):
|
|
264
|
+
fill = with_alpha(col, alpha) if col is not None else None
|
|
265
|
+
for i, (a, b) in enumerate(zip(edges, edges[1:])):
|
|
266
|
+
lo, hi = bots[i], tops[i]
|
|
267
|
+
if not (math.isfinite(lo) and math.isfinite(hi)) or hi == lo:
|
|
268
|
+
continue
|
|
269
|
+
if dodge:
|
|
270
|
+
fa, fb = bmap.forward(a), bmap.forward(b)
|
|
271
|
+
d = (fb - fa) / n
|
|
272
|
+
p0, p1 = bmap.position(fa + k * d), bmap.position(fa + (k + 1) * d)
|
|
273
|
+
else:
|
|
274
|
+
p0, p1 = bmap(a), bmap(b)
|
|
275
|
+
v0, v1 = vmap(lo), vmap(hi)
|
|
276
|
+
if horiz:
|
|
277
|
+
r = (min(v0, v1), min(p0, p1), max(v0, v1), max(p0, p1), hi < lo)
|
|
278
|
+
else:
|
|
279
|
+
r = (min(p0, p1), min(v0, v1), max(p0, p1), max(v0, v1), hi < lo)
|
|
280
|
+
rects.append(r)
|
|
281
|
+
texts_v.append(hi - lo)
|
|
282
|
+
xs.append(r[0])
|
|
283
|
+
ys.append(r[1])
|
|
284
|
+
ws.append(r[2] - r[0])
|
|
285
|
+
hs.append(r[3] - r[1])
|
|
286
|
+
fills.append(fill)
|
|
287
|
+
stroke = ctx.color(lc) if lc is not None and lw else None
|
|
288
|
+
draw_rects(ctx.canvas, xs, ys, ws, hs, fill=fills, stroke=stroke,
|
|
289
|
+
line_width=lw or None, simplify=self.simplify_arg())
|
|
290
|
+
self._labels = (rects, texts_v, fills)
|
|
291
|
+
return
|
|
292
|
+
lw = self.get("line_width")
|
|
293
|
+
for (bots, tops), col in zip(ends, cols):
|
|
294
|
+
outline = self._step_outline(edges, bots, tops)
|
|
295
|
+
if outline is None:
|
|
296
|
+
continue
|
|
297
|
+
(tb, tv), (bb, bv) = outline
|
|
298
|
+
if horiz:
|
|
299
|
+
top_x, top_y = vmap.many(tv), bmap.many(tb)
|
|
300
|
+
bot_x, bot_y = vmap.many(bv), bmap.many(bb)
|
|
301
|
+
else:
|
|
302
|
+
top_x, top_y = bmap.many(tb), vmap.many(tv)
|
|
303
|
+
bot_x, bot_y = bmap.many(bb), vmap.many(bv)
|
|
304
|
+
if kind == "stepfilled" and col is not None:
|
|
305
|
+
ring_x = array.array("d", top_x)
|
|
306
|
+
ring_x.extend(reversed(bot_x))
|
|
307
|
+
ring_y = array.array("d", top_y)
|
|
308
|
+
ring_y.extend(reversed(bot_y))
|
|
309
|
+
fill_polygons(ctx.canvas, [[(ring_x, ring_y)]], fill=with_alpha(col, alpha))
|
|
310
|
+
stroke = ctx.color(lc) if lc is not None else col
|
|
311
|
+
if stroke is not None and lw:
|
|
312
|
+
if kind == "step":
|
|
313
|
+
xs_ = array.array("d", [bot_x[0]])
|
|
314
|
+
xs_.extend(top_x)
|
|
315
|
+
xs_.append(bot_x[-1])
|
|
316
|
+
ys_ = array.array("d", [bot_y[0]])
|
|
317
|
+
ys_.extend(top_y)
|
|
318
|
+
ys_.append(bot_y[-1])
|
|
319
|
+
ctx.canvas.draw_lines(xs_, ys_, color=stroke, width=lw)
|
|
320
|
+
else:
|
|
321
|
+
ctx.canvas.draw_lines(top_x, top_y, color=stroke, width=lw)
|
|
322
|
+
|
|
323
|
+
def draw_overlay(self, ctx: Any) -> None:
|
|
324
|
+
where = self.get("labels")
|
|
325
|
+
lab = getattr(self, "_labels", None)
|
|
326
|
+
if not where or lab is None or self.get("kind") != "bar":
|
|
327
|
+
return
|
|
328
|
+
rects, values, fills = lab
|
|
329
|
+
texts = format_numbers(values, self.get("label_format"), NumberStyle.of(self))
|
|
330
|
+
font = Font(self.get("font_size"), self.get("font_family"),
|
|
331
|
+
color=ctx.color(self.get("text_color")))
|
|
332
|
+
bar_labels(ctx.canvas, rects, texts, fills, where, self._horizontal(), font)
|
|
333
|
+
|
|
334
|
+
@staticmethod
|
|
335
|
+
def _step_outline(edges: Any, bots: list[float], tops: list[float]) -> Any:
|
|
336
|
+
"""``((bin coords, values) of the top, (…) of the bottom)`` step polylines."""
|
|
337
|
+
tb, tv, bb, bv = (array.array("d") for _ in range(4))
|
|
338
|
+
for i, (a, b) in enumerate(zip(edges, edges[1:])):
|
|
339
|
+
lo, hi = bots[i], tops[i]
|
|
340
|
+
if not (math.isfinite(lo) and math.isfinite(hi)):
|
|
341
|
+
lo = hi = 0.0
|
|
342
|
+
tb.extend((a, b))
|
|
343
|
+
tv.extend((hi, hi))
|
|
344
|
+
bb.extend((a, b))
|
|
345
|
+
bv.extend((lo, lo))
|
|
346
|
+
if not len(tb):
|
|
347
|
+
return None
|
|
348
|
+
return (tb, tv), (bb, bv)
|
|
349
|
+
|
|
350
|
+
def legend_colors_at(self, ctx: Any, i: int) -> tuple[Any, Any]:
|
|
351
|
+
cols = self.set_colors(ctx)
|
|
352
|
+
col = cols[i] if i < len(cols) else None
|
|
353
|
+
fill = with_alpha(col, self._alpha()) if col is not None else None
|
|
354
|
+
if self.get("kind") == "step":
|
|
355
|
+
return None, col
|
|
356
|
+
lc = self.get("line_color")
|
|
357
|
+
return fill, (ctx.color(lc) if lc is not None and self.get("line_width") else None)
|
|
358
|
+
|
|
359
|
+
def legend_colors(self, ctx: Any) -> tuple[Any, Any]:
|
|
360
|
+
return self.legend_colors_at(ctx, 0)
|
|
361
|
+
|
|
362
|
+
def legend_glyph_at(self, ctx: Any, i: int, x: float, y: float, w: float, h: float) -> None:
|
|
363
|
+
fill, stroke = self.legend_colors_at(ctx, i)
|
|
364
|
+
canvas = ctx.canvas
|
|
365
|
+
if self.get("kind") == "step":
|
|
366
|
+
if stroke is not None:
|
|
367
|
+
canvas.draw_lines([x, x + w], [y + h / 2, y + h / 2], color=stroke,
|
|
368
|
+
width=self.get("line_width"))
|
|
369
|
+
return
|
|
370
|
+
canvas.begin_path()
|
|
371
|
+
canvas.rect(x, y, w, h)
|
|
372
|
+
if fill is not None:
|
|
373
|
+
canvas.fill(fill)
|
|
374
|
+
if stroke is not None:
|
|
375
|
+
canvas.line_width = self.get("line_width")
|
|
376
|
+
canvas.stroke(stroke)
|
|
377
|
+
|
|
378
|
+
def legend_glyph(self, ctx: Any, x: float, y: float, w: float, h: float) -> None:
|
|
379
|
+
self.legend_glyph_at(ctx, 0, x, y, w, h)
|