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
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Contours: iso-lines and filled bands of a grid of values."""
|
|
2
|
+
|
|
3
|
+
import array
|
|
4
|
+
import math
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .. import _backend, stats
|
|
8
|
+
from ..maps import ColorMap, check_cmap
|
|
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, table
|
|
12
|
+
from ..shapes import fill_ring_sets
|
|
13
|
+
from ..style import dash_of, with_alpha
|
|
14
|
+
from ..text import Font, draw_text, measure
|
|
15
|
+
from ..ticks import nice_step
|
|
16
|
+
from . import Series, common
|
|
17
|
+
from .heatmap import _Coords
|
|
18
|
+
|
|
19
|
+
__all__ = ["Contour"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _levels(v: Any) -> Any:
|
|
23
|
+
if isinstance(v, int) and not isinstance(v, bool):
|
|
24
|
+
return number(lo=1, integer=True)(v)
|
|
25
|
+
vals = sorted(float(x) for x in v)
|
|
26
|
+
if len(vals) < 1:
|
|
27
|
+
raise ValueError("give at least one level")
|
|
28
|
+
return vals
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _colors(v: Any) -> Any:
|
|
32
|
+
if isinstance(v, (list, tuple)) and v and not (
|
|
33
|
+
len(v) in (3, 4) and all(isinstance(c, (int, float)) for c in v)):
|
|
34
|
+
return [color(c) for c in v]
|
|
35
|
+
return color(v)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Contour(Series):
|
|
39
|
+
"""Contour lines and/or filled bands of ``values`` (rows first) at nodes ``x``/``y``
|
|
40
|
+
(centres; edges are reduced to their midpoints)."""
|
|
41
|
+
|
|
42
|
+
SETTINGS = table(
|
|
43
|
+
levels=Setting(_levels, doc="A number of levels (nice values over the data) or "
|
|
44
|
+
"the level values."),
|
|
45
|
+
filled=Setting(boolean, doc="Fill the bands between levels."),
|
|
46
|
+
lines=Setting(boolean, doc="Draw the iso-lines."),
|
|
47
|
+
color_map=Setting(check_cmap, inherited=True, doc="Color map of the levels."),
|
|
48
|
+
colors=Setting(_colors, doc="Line color, or one per level, instead of the map."),
|
|
49
|
+
line_width=Setting(number(lo=0), doc="Line width (pt).", length=True),
|
|
50
|
+
line_style=Setting(one_of("solid", "dashed", "dotted", "dashdot"),
|
|
51
|
+
doc="Line style."),
|
|
52
|
+
negative_style=Setting(either(literal(None), one_of("solid", "dashed", "dotted",
|
|
53
|
+
"dashdot")),
|
|
54
|
+
doc="Line style of negative levels drawn with a single color "
|
|
55
|
+
"(matplotlib: dashed)."),
|
|
56
|
+
alpha=Setting(number(0, 1), doc="Opacity."),
|
|
57
|
+
labels=Setting(boolean, doc="Write the level values on the lines."),
|
|
58
|
+
label_format=Setting(check_format, doc="Number format of the labels."),
|
|
59
|
+
label_size=Setting(number(positive=True), doc="Label font size (pt).", length=True),
|
|
60
|
+
colorbar=Setting(either(literal(False), literal(True)),
|
|
61
|
+
doc="Attach a colorbar to the plot."),
|
|
62
|
+
**common("name", "z", "rasterize", "simplify", "font_size", "font_family", "text_color"),
|
|
63
|
+
**number_settings(),
|
|
64
|
+
)
|
|
65
|
+
USES_PALETTE = False
|
|
66
|
+
|
|
67
|
+
def __init__(self, values: Any, x: Any = None, y: Any = None, plot: Any = None,
|
|
68
|
+
**settings: Any) -> None:
|
|
69
|
+
from pdf_io import arrays as pdf_arrays
|
|
70
|
+
g = pdf_arrays.grid(values)
|
|
71
|
+
if g is not None:
|
|
72
|
+
self.h, self.w, k, self.flat = g
|
|
73
|
+
if k != 1:
|
|
74
|
+
raise ValueError("contour values must be a 2-D grid of numbers")
|
|
75
|
+
else:
|
|
76
|
+
rows = [list(r) for r in values]
|
|
77
|
+
self.h, self.w = len(rows), len(rows[0]) if rows else 0
|
|
78
|
+
if any(len(r) != self.w for r in rows):
|
|
79
|
+
raise ValueError("contour rows must have the same length")
|
|
80
|
+
self.flat = _backend.doubles([v for r in rows for v in r])
|
|
81
|
+
if self.h < 2 or self.w < 2:
|
|
82
|
+
raise ValueError("contours need a grid of at least 2 × 2 values")
|
|
83
|
+
self.xn = self._nodes(x, self.w, "x")
|
|
84
|
+
self.yn = self._nodes(y, self.h, "y")
|
|
85
|
+
self._map: Any = None
|
|
86
|
+
self._bar: Any = None
|
|
87
|
+
super().__init__(plot=plot, **settings)
|
|
88
|
+
if self.get("colorbar"):
|
|
89
|
+
self._bar = self.plot.colorbar(self)
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _nodes(given: Any, n: int, what: str) -> array.array:
|
|
93
|
+
if given is None:
|
|
94
|
+
return array.array("d", (float(i) for i in range(n)))
|
|
95
|
+
c = _Coords(given, n, what)
|
|
96
|
+
if c.kind == "category":
|
|
97
|
+
raise ValueError("contour coordinates must be numbers")
|
|
98
|
+
v = c.values
|
|
99
|
+
if c.kind == "edges":
|
|
100
|
+
return array.array("d", ((a + b) / 2 for a, b in zip(v, v[1:])))
|
|
101
|
+
return array.array("d", v)
|
|
102
|
+
|
|
103
|
+
def _scales(self) -> tuple[Any, Any]:
|
|
104
|
+
out = []
|
|
105
|
+
for axis in (self.plot.x_axis, self.plot.y_axis):
|
|
106
|
+
name = axis.nominal_scale
|
|
107
|
+
out.append(get_scale("linear" if name == "category" else name))
|
|
108
|
+
return out[0], out[1]
|
|
109
|
+
|
|
110
|
+
# -- levels and map ---------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def level_values(self) -> list[float]:
|
|
113
|
+
lv = self.get("levels")
|
|
114
|
+
if isinstance(lv, list):
|
|
115
|
+
return lv
|
|
116
|
+
n, lo, hi = _backend.extent(self.flat, None)
|
|
117
|
+
if not n:
|
|
118
|
+
return [0.0]
|
|
119
|
+
if hi == lo:
|
|
120
|
+
return [lo]
|
|
121
|
+
step = nice_step((hi - lo) / lv)
|
|
122
|
+
start = math.floor(lo / step + 1e-9) * step
|
|
123
|
+
end = math.ceil(hi / step - 1e-9) * step
|
|
124
|
+
k = int(round((end - start) / step))
|
|
125
|
+
return [round(start + i * step, 12) for i in range(k + 1)]
|
|
126
|
+
|
|
127
|
+
def color_map(self) -> ColorMap:
|
|
128
|
+
cmap = self.get("color_map")
|
|
129
|
+
lv = self.level_values()
|
|
130
|
+
key = (id(cmap) if isinstance(cmap, ColorMap) else repr(cmap), tuple(lv))
|
|
131
|
+
if self._map is None or self._map[0] != key:
|
|
132
|
+
base = cmap if isinstance(cmap, ColorMap) else ColorMap(cmap)
|
|
133
|
+
lim = (lv[0], lv[-1]) if len(lv) > 1 else (lv[0] - 0.5, lv[0] + 0.5)
|
|
134
|
+
self._map = (key, ColorMap(base, limits=lim))
|
|
135
|
+
return self._map[1]
|
|
136
|
+
|
|
137
|
+
def color_sources(self) -> list[tuple[Any, Any]]:
|
|
138
|
+
if self.get("colors") is not None and not self.get("filled"):
|
|
139
|
+
return []
|
|
140
|
+
return [(self.color_map(), array.array("d", self.level_values()))]
|
|
141
|
+
|
|
142
|
+
def has_own_color(self) -> bool:
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def stats(self) -> dict[str, Any]:
|
|
147
|
+
"""``{"levels", "lines": {level: [(x, y, closed)]}, "bands": [(lo, hi, (xs, ys,
|
|
148
|
+
ring_starts))]}``, coordinates after the axis scale functions."""
|
|
149
|
+
sx, sy = self._scales()
|
|
150
|
+
return self.cached(("stats", sx.name, sy.name), lambda: self._compute(sx, sy))
|
|
151
|
+
|
|
152
|
+
def _compute(self, sx: Any, sy: Any) -> dict[str, Any]:
|
|
153
|
+
lv = self.level_values()
|
|
154
|
+
fx = array.array("d", (sx.f(v) for v in self.xn))
|
|
155
|
+
fy = array.array("d", (sy.f(v) for v in self.yn))
|
|
156
|
+
lines: dict[float, Any] = {}
|
|
157
|
+
if self.get("lines"):
|
|
158
|
+
for level in lv:
|
|
159
|
+
lines[level] = stats.contour_lines(self.flat, self.w, self.h, fx, fy, level)
|
|
160
|
+
bands = []
|
|
161
|
+
if self.get("filled"):
|
|
162
|
+
for lo, hi in zip(lv, lv[1:]):
|
|
163
|
+
top = hi if hi != lv[-1] else math.nextafter(hi, math.inf)
|
|
164
|
+
rings = stats.contour_bands(self.flat, self.w, self.h, fx, fy, lo, top)
|
|
165
|
+
if len(rings[0]):
|
|
166
|
+
bands.append((lo, hi, rings))
|
|
167
|
+
return {"levels": lv, "lines": lines, "bands": bands, "f_space": True}
|
|
168
|
+
|
|
169
|
+
# -- axes -------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def values(self, role: str) -> Any:
|
|
172
|
+
return self.xn if role == "x" else self.yn
|
|
173
|
+
|
|
174
|
+
def zero_padding(self, role: str) -> bool:
|
|
175
|
+
return True
|
|
176
|
+
|
|
177
|
+
def occupy(self, ctx: Any, occ: Any) -> None:
|
|
178
|
+
self.occupy_all(ctx, occ)
|
|
179
|
+
|
|
180
|
+
def legend_points(self) -> tuple[Any, Any] | None:
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
# -- drawing ----------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
def _level_color(self, ctx: Any, i: int, level: float) -> Any:
|
|
186
|
+
given = self.get("colors")
|
|
187
|
+
if given is not None:
|
|
188
|
+
return ctx.color(given[i % len(given)] if isinstance(given, list) else given)
|
|
189
|
+
cmap = self.color_map()
|
|
190
|
+
flo, fhi = ctx.maps[id(cmap)][:2]
|
|
191
|
+
span = (fhi - flo) or 1.0
|
|
192
|
+
return cmap((level - flo) / span)
|
|
193
|
+
|
|
194
|
+
def draw(self, ctx: Any) -> None:
|
|
195
|
+
st = self.stats
|
|
196
|
+
canvas = ctx.canvas
|
|
197
|
+
alpha = self.get("alpha")
|
|
198
|
+
xm, ym = ctx.xmap, ctx.ymap
|
|
199
|
+
|
|
200
|
+
def px(v: Any) -> Any:
|
|
201
|
+
return _backend.scaled(v, xm.a, xm.b)
|
|
202
|
+
|
|
203
|
+
def py(v: Any) -> Any:
|
|
204
|
+
return _backend.scaled(v, ym.a, ym.b)
|
|
205
|
+
|
|
206
|
+
if st["bands"]:
|
|
207
|
+
cmap = self.color_map()
|
|
208
|
+
flo, fhi = ctx.maps[id(cmap)][:2]
|
|
209
|
+
span = (fhi - flo) or 1.0
|
|
210
|
+
sets, fills = [], []
|
|
211
|
+
for lo, hi, (xs, ys, starts) in st["bands"]:
|
|
212
|
+
sets.append((px(xs), py(ys), starts))
|
|
213
|
+
fills.append(with_alpha(cmap(((lo + hi) / 2 - flo) / span), alpha))
|
|
214
|
+
fill_ring_sets(canvas, sets, fills, simplify=self.simplify_arg())
|
|
215
|
+
lw = self.get("line_width")
|
|
216
|
+
if not st["lines"] or not lw:
|
|
217
|
+
return
|
|
218
|
+
style = self.get("line_style")
|
|
219
|
+
neg = self.get("negative_style")
|
|
220
|
+
single = self.get("colors") is not None and not isinstance(self.get("colors"), list)
|
|
221
|
+
self._drawn: list[tuple[float, Any, list[Any]]] = []
|
|
222
|
+
for i, level in enumerate(st["levels"]):
|
|
223
|
+
polylines = st["lines"].get(level) or []
|
|
224
|
+
if not polylines:
|
|
225
|
+
continue
|
|
226
|
+
c = with_alpha(self._level_color(ctx, i, level), alpha)
|
|
227
|
+
ls = neg if (neg is not None and single and level < 0) else style
|
|
228
|
+
xs = [px(p[0]) for p in polylines]
|
|
229
|
+
ys = [py(p[1]) for p in polylines]
|
|
230
|
+
canvas.draw_lines(xs, ys, color=c, width=lw, dash=dash_of(ls, lw),
|
|
231
|
+
simplify=self.simplify_arg(lines=True))
|
|
232
|
+
self._drawn.append((level, c, list(zip(xs, ys))))
|
|
233
|
+
|
|
234
|
+
def draw_overlay(self, ctx: Any) -> None:
|
|
235
|
+
if not self.get("labels") or not getattr(self, "_drawn", None):
|
|
236
|
+
return
|
|
237
|
+
canvas = ctx.canvas
|
|
238
|
+
levels = [lv for lv, _, _ in self._drawn]
|
|
239
|
+
texts = format_numbers(levels, self.get("label_format"), NumberStyle.of(self))
|
|
240
|
+
size = self.get("label_size")
|
|
241
|
+
fig = self.plot.figure
|
|
242
|
+
bg = next((c for c in (self.plot.get("background"), fig.get("background"),
|
|
243
|
+
fig.get("image_background")) if c is not None))
|
|
244
|
+
bg = ctx.color(bg)
|
|
245
|
+
x0, y0, x1, y1 = ctx.rect
|
|
246
|
+
for (level, c, lines), text in zip(self._drawn, texts):
|
|
247
|
+
font = Font(size, self.get("font_family"), color=c)
|
|
248
|
+
w, a, d = measure(canvas, text, font)
|
|
249
|
+
for xs, ys in lines:
|
|
250
|
+
n = len(xs)
|
|
251
|
+
if n < 3:
|
|
252
|
+
continue
|
|
253
|
+
seg = [math.hypot(xs[i + 1] - xs[i], ys[i + 1] - ys[i]) for i in range(n - 1)]
|
|
254
|
+
total = sum(seg)
|
|
255
|
+
if total < 3 * w:
|
|
256
|
+
continue
|
|
257
|
+
# the label goes at the middle of the line
|
|
258
|
+
half, acc, i = total / 2, 0.0, 0
|
|
259
|
+
while i < n - 2 and acc + seg[i] < half:
|
|
260
|
+
acc += seg[i]
|
|
261
|
+
i += 1
|
|
262
|
+
t = (half - acc) / seg[i] if seg[i] else 0.0
|
|
263
|
+
cx = xs[i] + t * (xs[i + 1] - xs[i])
|
|
264
|
+
cy = ys[i] + t * (ys[i + 1] - ys[i])
|
|
265
|
+
if not (x0 <= cx <= x1 and y0 <= cy <= y1):
|
|
266
|
+
continue
|
|
267
|
+
ang = math.degrees(math.atan2(-(ys[i + 1] - ys[i]), xs[i + 1] - xs[i]))
|
|
268
|
+
if ang > 90:
|
|
269
|
+
ang -= 180
|
|
270
|
+
elif ang < -90:
|
|
271
|
+
ang += 180
|
|
272
|
+
if not self.get("filled"):
|
|
273
|
+
canvas.save()
|
|
274
|
+
canvas.translate(cx, cy)
|
|
275
|
+
canvas.rotate(-ang)
|
|
276
|
+
canvas.fill_style = bg
|
|
277
|
+
pad = 0.25 * size
|
|
278
|
+
canvas.fill_rect(-w / 2 - pad, -(a + d) / 2, w + 2 * pad, a + d)
|
|
279
|
+
canvas.restore()
|
|
280
|
+
draw_text(canvas, text, cx, cy, font, "center", "center", ang)
|
|
281
|
+
|
|
282
|
+
def legend_colors(self, ctx: Any) -> tuple[Any, Any]:
|
|
283
|
+
lv = self.level_values()
|
|
284
|
+
c = self._level_color(ctx, len(lv) // 2, lv[len(lv) // 2])
|
|
285
|
+
return (c if self.get("filled") else None), c
|
|
286
|
+
|
|
287
|
+
def legend_glyph(self, ctx: Any, x: float, y: float, w: float, h: float) -> None:
|
|
288
|
+
fill, c = self.legend_colors(ctx)
|
|
289
|
+
if fill is not None:
|
|
290
|
+
ctx.canvas.fill_style = fill
|
|
291
|
+
ctx.canvas.fill_rect(x, y, w, h)
|
|
292
|
+
elif c is not None:
|
|
293
|
+
ctx.canvas.draw_lines([x, x + w], [y + h / 2, y + h / 2], color=c,
|
|
294
|
+
width=self.get("line_width"))
|
plot_io/series/curves.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Line and fill interpolation and missing values.
|
|
2
|
+
|
|
3
|
+
pdf_io draws every mode in C (``interpolation=``, ``missing=`` on ``draw_lines`` and
|
|
4
|
+
``fill_between``). Horizontal fills are drawn here: their steps and skipped points are
|
|
5
|
+
expanded by :func:`expand`, and curves are drawn as straight segments.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import array
|
|
9
|
+
import math
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ..settings import Setting, one_of
|
|
13
|
+
|
|
14
|
+
__all__ = ["INTERPOLATIONS", "curve_settings", "line_args", "expand"]
|
|
15
|
+
|
|
16
|
+
INTERPOLATIONS = ("linear", "step-pre", "step-post", "step-mid", "monotone", "spline")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _interpolation(v: Any) -> str:
|
|
20
|
+
s = str(getattr(v, "value", v)).strip().lower().replace("_", "-")
|
|
21
|
+
if s not in INTERPOLATIONS:
|
|
22
|
+
raise ValueError(f"expected one of {', '.join(map(repr, INTERPOLATIONS))}, got {v!r}")
|
|
23
|
+
return s
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def curve_settings() -> dict[str, Setting]:
|
|
27
|
+
return {
|
|
28
|
+
"interpolation": Setting(
|
|
29
|
+
_interpolation,
|
|
30
|
+
doc="How points are joined: `linear`, `step-pre`, `step-post`, `step-mid`, "
|
|
31
|
+
"`monotone` (a curve that never overshoots) or `spline` (centripetal "
|
|
32
|
+
"Catmull–Rom)."),
|
|
33
|
+
"missing": Setting(one_of("break", "skip"),
|
|
34
|
+
doc="Missing values: `break` the line there, or `skip` them and "
|
|
35
|
+
"connect their neighbours."),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def interpolation_of(series: Any) -> str:
|
|
40
|
+
return series.get("interpolation")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def line_args(series: Any) -> dict[str, Any]:
|
|
44
|
+
"""``interpolation``/``missing`` keywords for pdf_io's line and area calls."""
|
|
45
|
+
return {"interpolation": interpolation_of(series), "missing": series.get("missing")}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _runs(cols: list[Any], skip: bool) -> list[list[int]]:
|
|
49
|
+
n = len(cols[0])
|
|
50
|
+
ok = [all(math.isfinite(c[i]) if not isinstance(c, float) else True for c in cols)
|
|
51
|
+
for i in range(n)]
|
|
52
|
+
if skip:
|
|
53
|
+
return [[i for i in range(n) if ok[i]]]
|
|
54
|
+
runs: list[list[int]] = []
|
|
55
|
+
cur: list[int] = []
|
|
56
|
+
for i in range(n):
|
|
57
|
+
if ok[i]:
|
|
58
|
+
cur.append(i)
|
|
59
|
+
elif cur:
|
|
60
|
+
runs.append(cur)
|
|
61
|
+
cur = []
|
|
62
|
+
if cur:
|
|
63
|
+
runs.append(cur)
|
|
64
|
+
return runs
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _step(xs: list[float], ys: list[list[float]], where: str) -> tuple[list[float], list[list[float]]]:
|
|
68
|
+
n = len(xs)
|
|
69
|
+
if n < 2:
|
|
70
|
+
return xs, ys
|
|
71
|
+
ox: list[float] = []
|
|
72
|
+
oy: list[list[float]] = [[] for _ in ys]
|
|
73
|
+
if where == "step-post":
|
|
74
|
+
for i in range(n - 1):
|
|
75
|
+
ox += [xs[i], xs[i + 1]]
|
|
76
|
+
for k, y in enumerate(ys):
|
|
77
|
+
oy[k] += [y[i], y[i]]
|
|
78
|
+
ox.append(xs[-1])
|
|
79
|
+
for k, y in enumerate(ys):
|
|
80
|
+
oy[k].append(y[-1])
|
|
81
|
+
elif where == "step-pre":
|
|
82
|
+
ox.append(xs[0])
|
|
83
|
+
for k, y in enumerate(ys):
|
|
84
|
+
oy[k].append(y[0])
|
|
85
|
+
for i in range(1, n):
|
|
86
|
+
ox += [xs[i - 1], xs[i]]
|
|
87
|
+
for k, y in enumerate(ys):
|
|
88
|
+
oy[k] += [y[i], y[i]]
|
|
89
|
+
else:
|
|
90
|
+
ox.append(xs[0])
|
|
91
|
+
for k, y in enumerate(ys):
|
|
92
|
+
oy[k].append(y[0])
|
|
93
|
+
for i in range(n - 1):
|
|
94
|
+
m = (xs[i] + xs[i + 1]) / 2
|
|
95
|
+
ox += [m, m]
|
|
96
|
+
for k, y in enumerate(ys):
|
|
97
|
+
oy[k] += [y[i], y[i + 1]]
|
|
98
|
+
ox.append(xs[-1])
|
|
99
|
+
for k, y in enumerate(ys):
|
|
100
|
+
oy[k].append(y[-1])
|
|
101
|
+
return ox, oy
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def expand(x: Any, ys: list[Any], interpolation: str, missing: str) -> tuple[Any, list[Any]]:
|
|
105
|
+
"""Points for pdf_io versions without interpolation: missing points dropped (skip) or
|
|
106
|
+
runs separated by NaN (break), steps expanded per run. Constant ``ys`` entries
|
|
107
|
+
(floats) stay constant."""
|
|
108
|
+
if interpolation == "linear" and missing == "break":
|
|
109
|
+
return x, ys
|
|
110
|
+
nan = math.nan
|
|
111
|
+
runs = _runs([x] + ys, missing == "skip")
|
|
112
|
+
ox = array.array("d")
|
|
113
|
+
oy: list[Any] = [array.array("d") if not isinstance(y, float) else y for y in ys]
|
|
114
|
+
for r_i, run in enumerate(runs):
|
|
115
|
+
if r_i:
|
|
116
|
+
ox.append(nan)
|
|
117
|
+
for k, y in enumerate(ys):
|
|
118
|
+
if not isinstance(y, float):
|
|
119
|
+
oy[k].append(nan)
|
|
120
|
+
rx = [x[i] for i in run]
|
|
121
|
+
ry = [[y[i] if not isinstance(y, float) else y for i in run] for y in ys]
|
|
122
|
+
if interpolation.startswith("step"):
|
|
123
|
+
rx, ry = _step(rx, ry, interpolation)
|
|
124
|
+
ox.extend(rx)
|
|
125
|
+
for k, y in enumerate(ys):
|
|
126
|
+
if not isinstance(y, float):
|
|
127
|
+
oy[k].extend(ry[k])
|
|
128
|
+
return ox, oy
|
plot_io/series/ecdf.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Empirical cumulative distributions: a step line through the sorted values."""
|
|
2
|
+
|
|
3
|
+
import array
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .. import stats
|
|
7
|
+
from ..settings import Setting, boolean, color, number, one_of, table
|
|
8
|
+
from ..style import dash_of, with_alpha
|
|
9
|
+
from . import Series, common
|
|
10
|
+
from .hist import value_sets, _weights
|
|
11
|
+
|
|
12
|
+
__all__ = ["Ecdf"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Ecdf(Series):
|
|
16
|
+
"""The empirical cumulative distribution of ``values`` (one array, or several as a dict
|
|
17
|
+
or a list of arrays)."""
|
|
18
|
+
|
|
19
|
+
SETTINGS = table(
|
|
20
|
+
weights=Setting(_weights, doc="A weight per value (per set for several sets)."),
|
|
21
|
+
stat=Setting(one_of("proportion", "count", "percent"),
|
|
22
|
+
doc="`proportion` (0–1), `count` or `percent`."),
|
|
23
|
+
complementary=Setting(boolean, doc="Plot 1 − F (survival)."),
|
|
24
|
+
orientation=Setting(one_of("vertical", "horizontal"),
|
|
25
|
+
doc="`vertical`: values along x; `horizontal`: along y."),
|
|
26
|
+
color=Setting(color, doc="Line color, or one per set; default palette colors."),
|
|
27
|
+
line_width=Setting(number(lo=0), inherited=True, doc="Line width (pt).", length=True),
|
|
28
|
+
line_style=Setting(one_of("solid", "dashed", "dotted", "dashdot"),
|
|
29
|
+
doc="Line style."),
|
|
30
|
+
**common("name", "alpha", "z", "rasterize", "simplify", "font_size", "font_family",
|
|
31
|
+
"text_color"),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def __init__(self, values: Any, plot: Any = None, **settings: Any) -> None:
|
|
35
|
+
self.sets, self.named = value_sets(values)
|
|
36
|
+
super().__init__(plot=plot, **settings)
|
|
37
|
+
|
|
38
|
+
def _horizontal(self) -> bool:
|
|
39
|
+
return self.get("orientation") == "horizontal"
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def stats(self) -> list[tuple[array.array, array.array, float]]:
|
|
43
|
+
"""Per set: sorted values, the plotted cumulative values, and the value before the
|
|
44
|
+
first step (0, or the total for complementary curves)."""
|
|
45
|
+
return self.cached("stats", self._compute)
|
|
46
|
+
|
|
47
|
+
def _compute(self) -> list[tuple[array.array, array.array, float]]:
|
|
48
|
+
weights = self.get("weights")
|
|
49
|
+
st = self.get("stat")
|
|
50
|
+
out = []
|
|
51
|
+
for i, (key, v) in enumerate(self.sets):
|
|
52
|
+
w = None
|
|
53
|
+
if weights is not None:
|
|
54
|
+
w = (weights[key] if isinstance(weights, dict) else
|
|
55
|
+
weights[i] if isinstance(weights, list) else weights)
|
|
56
|
+
xs, cs = stats.ecdf(v, w)
|
|
57
|
+
total = cs[-1] if len(cs) else 0.0
|
|
58
|
+
k = 1.0 if st == "count" else (100.0 if st == "percent" else 1.0) / (total or 1.0)
|
|
59
|
+
top = total * k
|
|
60
|
+
ys = array.array("d", (c * k for c in cs))
|
|
61
|
+
start = 0.0
|
|
62
|
+
if self.get("complementary"):
|
|
63
|
+
ys = array.array("d", (top - y for y in ys))
|
|
64
|
+
start = top
|
|
65
|
+
out.append((xs, ys, start))
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
def values(self, role: str) -> Any:
|
|
69
|
+
val_role = "y" if self._horizontal() else "x"
|
|
70
|
+
out = array.array("d")
|
|
71
|
+
for xs, ys, start in self.stats:
|
|
72
|
+
if role == val_role:
|
|
73
|
+
out.extend(xs)
|
|
74
|
+
else:
|
|
75
|
+
out.extend(ys)
|
|
76
|
+
out.append(start)
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
def palette_count(self) -> int:
|
|
80
|
+
return len(self.sets)
|
|
81
|
+
|
|
82
|
+
def has_own_color(self) -> bool:
|
|
83
|
+
return self.is_set("color")
|
|
84
|
+
|
|
85
|
+
def legend_labels(self) -> list[Any]:
|
|
86
|
+
if self.named:
|
|
87
|
+
return [str(k) for k, _ in self.sets]
|
|
88
|
+
name = self.get("name")
|
|
89
|
+
return [] if name is None else [name]
|
|
90
|
+
|
|
91
|
+
def _colors(self, ctx: Any) -> list[Any]:
|
|
92
|
+
c = self.get("color")
|
|
93
|
+
cols = [ctx.color(c)] * len(self.sets) if c is not None else self.auto_colors(ctx)
|
|
94
|
+
return [with_alpha(x, self.get("alpha")) for x in cols]
|
|
95
|
+
|
|
96
|
+
def draw(self, ctx: Any) -> None:
|
|
97
|
+
lw = self.get("line_width")
|
|
98
|
+
dash = dash_of(self.get("line_style"), lw)
|
|
99
|
+
horiz = self._horizontal()
|
|
100
|
+
for (xs, ys, start), col in zip(self.stats, self._colors(ctx)):
|
|
101
|
+
if not len(xs) or col is None:
|
|
102
|
+
continue
|
|
103
|
+
vx = array.array("d", [xs[0]])
|
|
104
|
+
vx.extend(xs)
|
|
105
|
+
cy = array.array("d", [start])
|
|
106
|
+
cy.extend(ys)
|
|
107
|
+
px, py = (cy, vx) if horiz else (vx, cy)
|
|
108
|
+
ctx.canvas.draw_lines(ctx.xmap.many(px), ctx.ymap.many(py), color=col, width=lw,
|
|
109
|
+
dash=dash, interpolation="step-pre" if horiz else "step-post",
|
|
110
|
+
simplify=self.simplify_arg(lines=True))
|
|
111
|
+
|
|
112
|
+
def occupy(self, ctx: Any, occ: Any) -> None:
|
|
113
|
+
from ..occupancy import MAX_VERTICES, thin
|
|
114
|
+
for xs, ys, start in self.stats:
|
|
115
|
+
if not len(xs):
|
|
116
|
+
continue
|
|
117
|
+
last_x, last_y = xs[-1], ys[-1]
|
|
118
|
+
xs, ys = thin(xs, ys, limit=MAX_VERTICES // 2)
|
|
119
|
+
vx = [xs[0]] + list(xs) + [last_x]
|
|
120
|
+
cy = [start] + list(ys) + [last_y]
|
|
121
|
+
sx, sy = [], []
|
|
122
|
+
for i in range(len(vx)):
|
|
123
|
+
if i:
|
|
124
|
+
sx.append(vx[i])
|
|
125
|
+
sy.append(cy[i - 1])
|
|
126
|
+
sx.append(vx[i])
|
|
127
|
+
sy.append(cy[i])
|
|
128
|
+
px, py = (sy, sx) if self._horizontal() else (sx, sy)
|
|
129
|
+
occ.lines(ctx.xmap.many(px), ctx.ymap.many(py))
|
|
130
|
+
|
|
131
|
+
def legend_colors_at(self, ctx: Any, i: int) -> tuple[Any, Any]:
|
|
132
|
+
cols = self._colors(ctx)
|
|
133
|
+
return None, cols[i] if i < len(cols) else None
|
|
134
|
+
|
|
135
|
+
def legend_glyph_at(self, ctx: Any, i: int, x: float, y: float, w: float, h: float) -> None:
|
|
136
|
+
_, c = self.legend_colors_at(ctx, i)
|
|
137
|
+
if c is not None:
|
|
138
|
+
lw = self.get("line_width")
|
|
139
|
+
ctx.canvas.draw_lines([x, x + w], [y + h / 2, y + h / 2], color=c, width=lw,
|
|
140
|
+
dash=dash_of(self.get("line_style"), lw))
|
|
141
|
+
|
|
142
|
+
def legend_glyph(self, ctx: Any, x: float, y: float, w: float, h: float) -> None:
|
|
143
|
+
self.legend_glyph_at(ctx, 0, x, y, w, h)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Error bars on their own: bars (or a band) around points, with optional markers."""
|
|
2
|
+
|
|
3
|
+
import array
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .. import _backend
|
|
7
|
+
from ..settings import Setting, color, number, one_of, table
|
|
8
|
+
from ..style import with_alpha
|
|
9
|
+
from . import Series, common
|
|
10
|
+
from .errors import error_value
|
|
11
|
+
from .points import MarkerStyle, marker_settings
|
|
12
|
+
|
|
13
|
+
__all__ = ["Errorbar"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _marker(v: Any) -> Any:
|
|
17
|
+
from ..markers import check_marker
|
|
18
|
+
return check_marker(v)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Errorbar(Series):
|
|
22
|
+
"""Error bars at ``(x, y)``: ``y_error``/``x_error`` as a value, a sequence,
|
|
23
|
+
``(low, high)`` or ``ErrorBars``; ``marker`` adds markers at the points."""
|
|
24
|
+
|
|
25
|
+
SETTINGS = table(
|
|
26
|
+
**{**marker_settings("marker_"),
|
|
27
|
+
"marker": Setting(_marker, doc="Marker at the points; default none.")},
|
|
28
|
+
color=Setting(color, doc="Bar color; default: the next palette color."),
|
|
29
|
+
line_width=Setting(number(lo=0), inherited=True, doc="Bar width (pt).", length=True),
|
|
30
|
+
cap=Setting(number(lo=0), doc="Cap width (pt); 0: no caps.", length=True),
|
|
31
|
+
x_error=Setting(error_value, doc="Horizontal errors."),
|
|
32
|
+
y_error=Setting(error_value, doc="Vertical errors."),
|
|
33
|
+
y_error_style=Setting(one_of("bars", "band"),
|
|
34
|
+
doc="`bars`, or `band`: a filled band between the y error ends "
|
|
35
|
+
"(points sorted by x)."),
|
|
36
|
+
band_alpha=Setting(number(0, 1), doc="Opacity of a band."),
|
|
37
|
+
**common("name", "alpha", "z", "rasterize", "simplify", "font_size", "font_family",
|
|
38
|
+
"text_color"),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def __init__(self, x: Any, y: Any, plot: Any = None, **settings: Any) -> None:
|
|
42
|
+
self.x, self.y = _backend.doubles(x), _backend.doubles(y)
|
|
43
|
+
if len(self.x) != len(self.y):
|
|
44
|
+
raise ValueError(f"x has {len(self.x)} values and y {len(self.y)}")
|
|
45
|
+
super().__init__(plot=plot, **settings)
|
|
46
|
+
|
|
47
|
+
def _error(self, role: str) -> Any:
|
|
48
|
+
e = self.get("x_error" if role == "x" else "y_error")
|
|
49
|
+
if e is not None:
|
|
50
|
+
e.bind(len(self.x))
|
|
51
|
+
if self.get("cap") != 3.0 and e.cap == 3.0:
|
|
52
|
+
e.cap = self.get("cap")
|
|
53
|
+
return e
|
|
54
|
+
|
|
55
|
+
def values(self, role: str) -> Any:
|
|
56
|
+
v = self.x if role == "x" else self.y
|
|
57
|
+
e = self._error(role)
|
|
58
|
+
if e is None:
|
|
59
|
+
return v
|
|
60
|
+
lo, hi = e.ends(v)
|
|
61
|
+
out = array.array("d", v)
|
|
62
|
+
out.extend(lo)
|
|
63
|
+
out.extend(hi)
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
def legend_points(self) -> tuple[Any, Any]:
|
|
67
|
+
return self.x, self.y
|
|
68
|
+
|
|
69
|
+
def _color(self, ctx: Any) -> Any:
|
|
70
|
+
return with_alpha(self.base_color(ctx), self.get("alpha"))
|
|
71
|
+
|
|
72
|
+
def draw(self, ctx: Any) -> None:
|
|
73
|
+
c = self._color(ctx)
|
|
74
|
+
lw = self.get("line_width")
|
|
75
|
+
simplify = self.simplify_arg()
|
|
76
|
+
ey = self._error("y")
|
|
77
|
+
if ey is not None:
|
|
78
|
+
if self.get("y_error_style") == "band":
|
|
79
|
+
lo, hi = ey.ends(self.y)
|
|
80
|
+
order = sorted(range(len(self.x)), key=lambda i: self.x[i])
|
|
81
|
+
xs = array.array("d", (self.x[i] for i in order))
|
|
82
|
+
lo = array.array("d", (lo[i] for i in order))
|
|
83
|
+
hi = array.array("d", (hi[i] for i in order))
|
|
84
|
+
if c is not None:
|
|
85
|
+
ctx.canvas.fill_between(ctx.xmap.many(xs), ctx.ymap.many(lo),
|
|
86
|
+
ctx.ymap.many(hi), color=c,
|
|
87
|
+
opacity=self.get("band_alpha"))
|
|
88
|
+
else:
|
|
89
|
+
ey.draw(ctx, self.x, self.y, False, c, lw, simplify, 1.0)
|
|
90
|
+
ex = self._error("x")
|
|
91
|
+
if ex is not None:
|
|
92
|
+
ex.draw(ctx, self.x, self.y, True, c, lw, simplify, 1.0)
|
|
93
|
+
if self.get("marker") is not None:
|
|
94
|
+
style = MarkerStyle(self, ctx, "marker_", c, len(self.x), False)
|
|
95
|
+
style.draw(ctx.canvas, ctx.xmap.many(self.x), ctx.ymap.many(self.y), simplify)
|
|
96
|
+
|
|
97
|
+
def legend_colors(self, ctx: Any) -> tuple[Any, Any]:
|
|
98
|
+
return None, self._color(ctx)
|
|
99
|
+
|
|
100
|
+
def legend_glyph(self, ctx: Any, x: float, y: float, w: float, h: float) -> None:
|
|
101
|
+
c = self._color(ctx)
|
|
102
|
+
if c is None:
|
|
103
|
+
return
|
|
104
|
+
canvas = ctx.canvas
|
|
105
|
+
lw = self.get("line_width")
|
|
106
|
+
cx = x + w / 2
|
|
107
|
+
canvas.draw_lines([cx, cx], [y, y + h], color=c, width=lw)
|
|
108
|
+
canvas.draw_segments([cx - 2, cx - 2], [y, y + h], [cx + 2, cx + 2], [y, y + h],
|
|
109
|
+
color=c, width=lw)
|
|
110
|
+
if self.get("marker") is not None:
|
|
111
|
+
MarkerStyle(self, ctx, "marker_", c, 1, True).glyph(canvas, cx, y + h / 2, h * 0.8)
|