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,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)