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.
Files changed (65) hide show
  1. plot_io/__init__.py +66 -0
  2. plot_io/_backend.py +114 -0
  3. plot_io/axis.py +574 -0
  4. plot_io/colorbar.py +342 -0
  5. plot_io/decorations.py +496 -0
  6. plot_io/defaults.py +766 -0
  7. plot_io/density.py +97 -0
  8. plot_io/enums.py +63 -0
  9. plot_io/facets.py +184 -0
  10. plot_io/figure.py +331 -0
  11. plot_io/labels.py +63 -0
  12. plot_io/legend.py +498 -0
  13. plot_io/limits.py +92 -0
  14. plot_io/maps/__init__.py +365 -0
  15. plot_io/maps/data.py +333 -0
  16. plot_io/markers.py +117 -0
  17. plot_io/numbers.py +289 -0
  18. plot_io/occupancy.py +219 -0
  19. plot_io/palettes.py +142 -0
  20. plot_io/plot.py +435 -0
  21. plot_io/render.py +1122 -0
  22. plot_io/scales.py +93 -0
  23. plot_io/series/__init__.py +498 -0
  24. plot_io/series/area.py +181 -0
  25. plot_io/series/bar.py +294 -0
  26. plot_io/series/box.py +401 -0
  27. plot_io/series/catdata.py +23 -0
  28. plot_io/series/catpoints.py +182 -0
  29. plot_io/series/contour.py +294 -0
  30. plot_io/series/curves.py +128 -0
  31. plot_io/series/ecdf.py +143 -0
  32. plot_io/series/errorbar.py +111 -0
  33. plot_io/series/errors.py +100 -0
  34. plot_io/series/fill.py +168 -0
  35. plot_io/series/heatmap.py +304 -0
  36. plot_io/series/hexbin.py +216 -0
  37. plot_io/series/hist.py +379 -0
  38. plot_io/series/hist2d.py +163 -0
  39. plot_io/series/kde.py +228 -0
  40. plot_io/series/kde2d.py +153 -0
  41. plot_io/series/line.py +173 -0
  42. plot_io/series/pie.py +304 -0
  43. plot_io/series/points.py +341 -0
  44. plot_io/series/regression.py +250 -0
  45. plot_io/series/scatter.py +103 -0
  46. plot_io/series/stem.py +112 -0
  47. plot_io/series/violin.py +321 -0
  48. plot_io/settings.py +281 -0
  49. plot_io/shapes.py +57 -0
  50. plot_io/sharing.py +160 -0
  51. plot_io/slots.py +52 -0
  52. plot_io/stacking.py +63 -0
  53. plot_io/stats.py +140 -0
  54. plot_io/style.py +71 -0
  55. plot_io/styles/__init__.py +234 -0
  56. plot_io/styles/library.py +732 -0
  57. plot_io/tables.py +141 -0
  58. plot_io/text.py +109 -0
  59. plot_io/ticks.py +284 -0
  60. plot_io/times.py +199 -0
  61. plot_io/values.py +161 -0
  62. plot_io-0.1.0.dist-info/METADATA +102 -0
  63. plot_io-0.1.0.dist-info/RECORD +65 -0
  64. plot_io-0.1.0.dist-info/WHEEL +4 -0
  65. 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"))
@@ -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)