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
plot_io/series/line.py ADDED
@@ -0,0 +1,173 @@
1
+ """Line: a polyline through points (or several sharing x)."""
2
+
3
+ import array
4
+ from typing import Any
5
+
6
+ from pdf_io import arrays as pdf_arrays
7
+
8
+ from .. import _backend
9
+ from ..settings import Setting, color, number, one_of, table
10
+ from ..style import dash_of
11
+ from . import Series, common
12
+ from .errors import error_value
13
+ from .curves import curve_settings, line_args
14
+ from .points import MarkerStyle, marker_settings
15
+
16
+ __all__ = ["Line"]
17
+
18
+
19
+ def _dash_value(v: Any) -> Any:
20
+ if isinstance(v, (list, tuple)):
21
+ return [number(lo=0)(x) for x in v]
22
+ return one_of("solid", "dashed", "dotted", "dashdot")(v)
23
+
24
+
25
+ def _rows(y: Any) -> list[array.array]:
26
+ rows = pdf_arrays.rows(y)
27
+ if rows is not None:
28
+ return rows
29
+ if isinstance(y, (list, tuple)) and y and all(
30
+ hasattr(r, "__len__") and not isinstance(r, str) for r in y):
31
+ return [_backend.doubles(r) for r in y]
32
+ return [_backend.doubles(y)]
33
+
34
+
35
+ class Line(Series):
36
+ """A line through ``(x, y)``; ``y`` may be 2-D (one line per row, sharing ``x``), and
37
+ ``x`` may be omitted (``0, 1, 2, …``)."""
38
+
39
+ SETTINGS = table(
40
+ color=Setting(color, doc="Line color; default: the next palette color."),
41
+ line_width=Setting(number(lo=0), inherited=True, doc="Line width (pt).", length=True),
42
+ line_style=Setting(_dash_value,
43
+ doc="`solid`, `dashed`, `dotted`, `dashdot` or a dash list (pt); "
44
+ "named styles scale with the width."),
45
+ **curve_settings(),
46
+ cap=Setting(one_of("butt", "round", "square"), doc="Line cap."),
47
+ join=Setting(one_of("miter", "round", "bevel"), doc="Line join."),
48
+ **{k: v for k, v in marker_settings("marker_").items()},
49
+ x_error=Setting(error_value, doc="Horizontal error bars at the points."),
50
+ y_error=Setting(error_value, doc="Vertical error bars at the points."),
51
+ show_markers=Setting(lambda v: bool(v),
52
+ doc="Draw markers at the points (also turned on by setting "
53
+ "`marker`)."),
54
+ **common("name", "alpha", "z", "rasterize", "simplify", "font_size", "font_family",
55
+ "text_color"),
56
+ )
57
+
58
+ def __init__(self, x: Any, y: Any = None, plot: Any = None, **settings: Any) -> None:
59
+ if y is None:
60
+ y = x
61
+ rows = _rows(y)
62
+ x = array.array("d", range(len(rows[0])))
63
+ else:
64
+ rows = _rows(y)
65
+ self.x = _backend.doubles(x)
66
+ for r in rows:
67
+ if len(r) != len(self.x):
68
+ raise ValueError(f"x has {len(self.x)} values and y {len(r)}")
69
+ self.ys = rows
70
+ self._markers_on = "marker" in settings
71
+ super().__init__(plot=plot, **settings)
72
+
73
+ @property
74
+ def y(self) -> array.array:
75
+ return self.ys[0]
76
+
77
+ def set(self, **settings: Any) -> "Line":
78
+ if "marker" in settings:
79
+ self._markers_on = settings["marker"] is not None
80
+ super().set(**settings)
81
+ return self
82
+
83
+ def _markers(self) -> bool:
84
+ return self._markers_on or bool(self.get("show_markers"))
85
+
86
+ def _error(self, role: str) -> Any:
87
+ e = self.get("x_error" if role == "x" else "y_error")
88
+ if e is not None:
89
+ e.bind(len(self.x))
90
+ return e
91
+
92
+ def values(self, role: str) -> Any:
93
+ if role == "x":
94
+ v = self.x
95
+ elif len(self.ys) == 1:
96
+ v = self.ys[0]
97
+ else:
98
+ v = array.array("d")
99
+ for r in self.ys:
100
+ v.extend(r)
101
+ e = self._error(role)
102
+ if e is None:
103
+ return v
104
+ center = self.x if role == "x" else self.ys[0]
105
+ lo, hi = e.ends(center)
106
+ out = array.array("d", v)
107
+ out.extend(lo)
108
+ out.extend(hi)
109
+ return out
110
+
111
+ def occupy(self, ctx: Any, occ: Any) -> None:
112
+ from ..occupancy import MAX_VERTICES, thin
113
+ for r in self.ys:
114
+ x, y = thin(self.x, r, limit=MAX_VERTICES)
115
+ occ.lines(ctx.xmap.many(x), ctx.ymap.many(y))
116
+
117
+ def legend_points(self) -> tuple[Any, Any]:
118
+ if len(self.ys) == 1:
119
+ return self.x, self.ys[0]
120
+ xs, ys = array.array("d"), array.array("d")
121
+ for r in self.ys:
122
+ xs.extend(self.x)
123
+ ys.extend(r)
124
+ return xs, ys
125
+
126
+ def _line_args(self, ctx: Any) -> tuple[Any, float, Any, float]:
127
+ c = self.base_color(ctx)
128
+ w = self.get("line_width")
129
+ alpha = self.get("alpha")
130
+ return c, w, dash_of(self.get("line_style"), w), 1.0 if alpha is None else alpha
131
+
132
+ def draw(self, ctx: Any) -> None:
133
+ c, w, dash, opacity = self._line_args(ctx)
134
+ simplify = self.simplify_arg()
135
+ canvas = ctx.canvas
136
+ for role in ("x", "y"):
137
+ e = self._error(role)
138
+ if e is not None:
139
+ e.draw(ctx, self.x, self.ys[0], role == "x", c, 0.8, simplify, opacity)
140
+ px = ctx.xmap.many(self.x)
141
+ xs_page = [px] * len(self.ys)
142
+ ys_page = [ctx.ymap.many(r) for r in self.ys]
143
+ args = line_args(self)
144
+ if c is not None and w:
145
+ canvas.line_cap = self.get("cap")
146
+ canvas.line_join = self.get("join")
147
+ canvas.draw_lines(xs_page if len(xs_page) > 1 else xs_page[0],
148
+ ys_page if len(ys_page) > 1 else ys_page[0],
149
+ color=c, width=w, dash=dash, opacity=opacity,
150
+ simplify=self.simplify_arg(lines=True),
151
+ **args)
152
+ if self._markers():
153
+ style = MarkerStyle(self, ctx, "marker_", c, len(self.x))
154
+ for r in self.ys:
155
+ style.draw(canvas, ctx.xmap.many(self.x), ctx.ymap.many(r), simplify)
156
+
157
+ def zero_default(self, role: str) -> bool:
158
+ return role == "y"
159
+
160
+ def legend_colors(self, ctx: Any) -> tuple[Any, Any]:
161
+ c = self.base_color(ctx)
162
+ return c, c
163
+
164
+ def legend_glyph(self, ctx: Any, x: float, y: float, w: float, h: float) -> None:
165
+ c, lw, dash, opacity = self._line_args(ctx)
166
+ canvas = ctx.canvas
167
+ cy = y + h / 2
168
+ if c is not None and lw:
169
+ canvas.draw_lines([x, x + w], [cy, cy], color=c, width=lw, dash=dash,
170
+ opacity=opacity)
171
+ if self._markers():
172
+ MarkerStyle(self, ctx, "marker_", c, len(self.x), glyph=True).glyph(
173
+ canvas, x + w / 2, cy, h * 1.1)
plot_io/series/pie.py ADDED
@@ -0,0 +1,304 @@
1
+ """Pie and donut charts, on a plot without axes."""
2
+
3
+ import array
4
+ import math
5
+ from typing import Any
6
+
7
+ import pdf_io
8
+
9
+ from .. import _backend
10
+ from ..labels import ink_for
11
+ from ..numbers import NumberStyle, check_format, format_numbers, number_settings
12
+ from ..settings import Setting, boolean, color, either, literal, number, one_of, table
13
+ from ..style import with_alpha
14
+ from ..text import Font, draw_text, measure
15
+ from . import Series, common
16
+
17
+ __all__ = ["Pie"]
18
+
19
+
20
+ def _colors(v: Any) -> list[Any]:
21
+ return [color(c) for c in v]
22
+
23
+
24
+ def _explode(v: Any) -> Any:
25
+ if isinstance(v, (int, float)) and not isinstance(v, bool):
26
+ return number(lo=0)(v)
27
+ return [number(lo=0)(x) for x in v]
28
+
29
+
30
+ def _arc(path: Any, cx: float, cy: float, r: float, a0: float, a1: float) -> None:
31
+ """Append an arc of radius ``r`` from angle ``a0`` to ``a1`` (radians, counterclockwise
32
+ on the page: y up) as cubic Béziers of at most 90°."""
33
+ n = max(1, math.ceil(abs(a1 - a0) / (math.pi / 2) - 1e-9))
34
+ step = (a1 - a0) / n
35
+ k = 4.0 / 3.0 * math.tan(step / 4.0)
36
+ for i in range(n):
37
+ t0, t1 = a0 + i * step, a0 + (i + 1) * step
38
+ x0, y0 = cx + r * math.cos(t0), cy - r * math.sin(t0)
39
+ x3, y3 = cx + r * math.cos(t1), cy - r * math.sin(t1)
40
+ x1, y1 = x0 - k * r * math.sin(t0), y0 - k * r * math.cos(t0)
41
+ x2, y2 = x3 + k * r * math.sin(t1), y3 + k * r * math.cos(t1)
42
+ path.curve_to(x1, y1, x2, y2, x3, y3)
43
+
44
+
45
+ class Pie(Series):
46
+ """A pie (or donut, with ``hole``) of ``values``: a sequence, or a dict
47
+ ``{label: value}``; the plot is drawn without axes."""
48
+
49
+ SETTINGS = table(
50
+ labels=Setting(lambda v: [str(x) for x in v],
51
+ doc="Wedge names (default: the dict's keys)."),
52
+ colors=Setting(_colors, doc="One color per wedge; default palette colors."),
53
+ start=Setting(number(), doc="Angle where the first wedge starts (degrees, 0: "
54
+ "3 o'clock, 90: 12 o'clock)."),
55
+ counterclockwise=Setting(boolean, doc="Direction of the wedges."),
56
+ hole=Setting(number(0, 0.95), doc="Radius of the hole (fraction of the "
57
+ "radius): a donut."),
58
+ explode=Setting(_explode, doc="Offset of the wedges from the centre (fraction of the "
59
+ "radius): one value or one per wedge."),
60
+ sort=Setting(boolean, doc="Sort the wedges by decreasing value."),
61
+ label_position=Setting(one_of("outside", "inside", "none"),
62
+ doc="Where the names go: `outside`, `inside` or `none` (in "
63
+ "the legend instead)."),
64
+ value_labels=Setting(either(literal(False), one_of("percent", "value")),
65
+ doc="Write each wedge's `percent` or `value` inside it (outside, "
66
+ "with a leader line, when it does not fit)."),
67
+ label_format=Setting(check_format,
68
+ doc="Number format of the value labels (percentages: of 100)."),
69
+ line_color=Setting(color, doc="Wedge outline color; `none`: no outline."),
70
+ line_width=Setting(number(lo=0), doc="Wedge outline width (pt).", length=True),
71
+ alpha=Setting(number(0, 1), doc="Opacity."),
72
+ **common("name", "z", "rasterize", "simplify", "font_size", "font_family", "text_color"),
73
+ **number_settings(),
74
+ )
75
+ AXES = False
76
+
77
+ def __init__(self, values: Any, plot: Any = None, **settings: Any) -> None:
78
+ if isinstance(values, dict):
79
+ self.keys: list[Any] = [str(k) for k in values]
80
+ vals = _backend.doubles(list(values.values()))
81
+ else:
82
+ vals = _backend.doubles(values)
83
+ self.keys = []
84
+ if any(not (v >= 0) for v in vals if math.isfinite(v)):
85
+ raise ValueError("pie values must be non-negative")
86
+ self.vals = vals
87
+ super().__init__(plot=plot, **settings)
88
+ if not self.plot.is_set("axes"):
89
+ self.plot.set(axes=False)
90
+ if not self.plot.legend.is_set("position"):
91
+ self.plot.legend.set(position="outside right")
92
+
93
+ # -- geometry ---------------------------------------------------------------------
94
+
95
+ def names(self) -> list[Any]:
96
+ given = self.get("labels")
97
+ if given is not None:
98
+ if len(given) != len(self.vals):
99
+ raise ValueError(f"{len(given)} labels for {len(self.vals)} wedges")
100
+ return given
101
+ return list(self.keys)
102
+
103
+ def order(self) -> list[int]:
104
+ idx = [i for i in range(len(self.vals)) if math.isfinite(self.vals[i]) and self.vals[i] > 0]
105
+ if self.get("sort"):
106
+ idx.sort(key=lambda i: -self.vals[i])
107
+ return idx
108
+
109
+ @property
110
+ def stats(self) -> list[dict[str, Any]]:
111
+ """Per wedge (in drawing order): ``index``, ``value``, ``fraction``, ``start`` and
112
+ ``end`` angles (degrees, counterclockwise from 3 o'clock)."""
113
+ order = self.order()
114
+ total = math.fsum(self.vals[i] for i in order)
115
+ sign = 1.0 if self.get("counterclockwise") else -1.0
116
+ a = self.get("start")
117
+ out = []
118
+ for i in order:
119
+ frac = self.vals[i] / total if total else 0.0
120
+ b = a + sign * 360.0 * frac
121
+ out.append({"index": i, "value": self.vals[i], "fraction": frac, "start": a, "end": b})
122
+ a = b
123
+ return out
124
+
125
+ def _explode_of(self, i: int) -> float:
126
+ e = self.get("explode")
127
+ if e is None:
128
+ return 0.0
129
+ if isinstance(e, list):
130
+ if len(e) != len(self.vals):
131
+ raise ValueError(f"{len(e)} explode values for {len(self.vals)} wedges")
132
+ return e[i]
133
+ return e
134
+
135
+ def _reach(self) -> float:
136
+ e = self.get("explode")
137
+ ex = max(e) if isinstance(e, list) and e else (e or 0.0)
138
+ out = self.get("label_position") == "outside" or self.get("value_labels")
139
+ return 1.0 + ex + (0.45 if out else 0.05)
140
+
141
+ def values(self, role: str) -> Any:
142
+ r = self._reach()
143
+ return array.array("d", [-r, r])
144
+
145
+ def zero_padding(self, role: str) -> bool:
146
+ return True
147
+
148
+ def palette_count(self) -> int:
149
+ return len(self.vals)
150
+
151
+ def has_own_color(self) -> bool:
152
+ return self.is_set("colors")
153
+
154
+ def legend_labels(self) -> list[Any]:
155
+ if self.get("label_position") == "none":
156
+ names = self.names()
157
+ return [names[w["index"]] for w in self.stats] if names else []
158
+ return []
159
+
160
+ def wedge_colors(self, ctx: Any) -> list[Any]:
161
+ """Colors in value order (one per value)."""
162
+ given = self.get("colors")
163
+ n = len(self.vals)
164
+ if given is not None:
165
+ if len(given) < n:
166
+ raise ValueError(f"{len(given)} colors for {n} wedges")
167
+ cols = [ctx.color(c) for c in given[:n]]
168
+ else:
169
+ cols = self.auto_colors(ctx)
170
+ cols = (cols * (n // max(len(cols), 1) + 1))[:n]
171
+ a = self.get("alpha")
172
+ return [with_alpha(c, a) for c in cols]
173
+
174
+ # -- drawing ----------------------------------------------------------------------
175
+
176
+ def _center(self, ctx: Any) -> tuple[float, float, float]:
177
+ cx, cy = ctx.xmap(0.0), ctx.ymap(0.0)
178
+ r = abs(ctx.xmap(1.0) - cx)
179
+ return cx, cy, r
180
+
181
+ def draw(self, ctx: Any) -> None:
182
+ cx, cy, r = self._center(ctx)
183
+ hole = self.get("hole")
184
+ cols = self.wedge_colors(ctx)
185
+ paths, fills = [], []
186
+ for w in self.stats:
187
+ i = w["index"]
188
+ a0, a1 = math.radians(w["start"]), math.radians(w["end"])
189
+ mid = (a0 + a1) / 2
190
+ ex = self._explode_of(i) * r
191
+ ox, oy = cx + ex * math.cos(mid), cy - ex * math.sin(mid)
192
+ p = pdf_io.Path()
193
+ if w["fraction"] >= 1.0 - 1e-12:
194
+ p.move_to(ox + r, oy)
195
+ _arc(p, ox, oy, r, 0.0, 2 * math.pi)
196
+ p.close()
197
+ if hole:
198
+ p.move_to(ox + hole * r, oy)
199
+ _arc(p, ox, oy, hole * r, 2 * math.pi, 0.0)
200
+ p.close()
201
+ else:
202
+ p.move_to(ox + r * math.cos(a0), oy - r * math.sin(a0))
203
+ _arc(p, ox, oy, r, a0, a1)
204
+ if hole:
205
+ p.line_to(ox + hole * r * math.cos(a1), oy - hole * r * math.sin(a1))
206
+ _arc(p, ox, oy, hole * r, a1, a0)
207
+ else:
208
+ p.line_to(ox, oy)
209
+ p.close()
210
+ paths.append(p)
211
+ fills.append(cols[i])
212
+ lc = ctx.color(self.get("line_color"))
213
+ lw = self.get("line_width")
214
+ if paths:
215
+ ctx.canvas.fill_polygons(paths, fill=fills, stroke=lc if lw else None,
216
+ line_width=lw or None, simplify=self.simplify_arg())
217
+
218
+ def draw_overlay(self, ctx: Any) -> None:
219
+ cx, cy, r = self._center(ctx)
220
+ hole = self.get("hole")
221
+ canvas = ctx.canvas
222
+ names = self.names()
223
+ pos = self.get("label_position")
224
+ vl = self.get("value_labels")
225
+ stats = self.stats
226
+ font = Font(self.get("font_size"), self.get("font_family"),
227
+ color=ctx.color(self.get("text_color")))
228
+ cols = self.wedge_colors(ctx)
229
+ texts: list[Any] = [None] * len(stats)
230
+ placed: list[list[Any]] = []
231
+ if vl:
232
+ vals = [100.0 * w["fraction"] if vl == "percent" else w["value"] for w in stats]
233
+ style = NumberStyle.of(self)
234
+ fmt = self.get("label_format")
235
+ if vl == "percent" and fmt == "auto":
236
+ style.suffix = style.suffix or "%"
237
+ if style.precision is None:
238
+ style.precision = 2
239
+ texts = format_numbers(vals, fmt, style)
240
+ for k, w in enumerate(stats):
241
+ i = w["index"]
242
+ a0, a1 = math.radians(w["start"]), math.radians(w["end"])
243
+ mid = (a0 + a1) / 2
244
+ ex = self._explode_of(i) * r
245
+ ox, oy = cx + ex * math.cos(mid), cy - ex * math.sin(mid)
246
+ ux, uy = math.cos(mid), -math.sin(mid)
247
+ side = "left" if ux >= 0 else "right"
248
+ outside_texts = []
249
+ if names and pos == "outside":
250
+ outside_texts.append(names[i])
251
+ if names and pos == "inside":
252
+ draw_text(canvas, names[i], ox + ux * r * (0.5 + hole / 2),
253
+ oy + uy * r * (0.5 + hole / 2),
254
+ Font(font.size, font.family, color=ink_for(cols[i])), "center",
255
+ "bottom" if vl else "center")
256
+ if texts[k] is not None:
257
+ rin = r * (0.5 + hole / 2) if hole else r * 0.62
258
+ tw, ta, td = measure(canvas, texts[k], font)
259
+ arc_len = abs(a1 - a0) * rin
260
+ fits = arc_len >= 1.2 * max(tw, ta + td) and (1 - hole) * r >= 1.1 * (ta + td)
261
+ if fits:
262
+ draw_text(canvas, texts[k], ox + ux * rin, oy + uy * rin,
263
+ Font(font.size, font.family, color=ink_for(cols[i])), "center",
264
+ "top" if names and pos == "inside" else "center")
265
+ else:
266
+ outside_texts.append(texts[k])
267
+ if outside_texts:
268
+ text = " ".join(str(t) for t in outside_texts) if all(
269
+ isinstance(t, str) for t in outside_texts) else outside_texts[0]
270
+ lead = not (names and pos == "outside") or len(outside_texts) > 1
271
+ placed.append([oy + uy * r * 1.12, ox + ux * r * 1.12, side, text, lead,
272
+ (ox + ux * r, oy + uy * r)])
273
+ self._outside_labels(canvas, placed, font)
274
+
275
+ @staticmethod
276
+ def _outside_labels(canvas: Any, placed: list[list[Any]], font: Font) -> None:
277
+ """Draw the outside labels, pushed apart vertically on each side so that they do
278
+ not overlap; moved labels get a leader line."""
279
+ gap = 1.25 * font.size
280
+ for side in ("left", "right"):
281
+ items = sorted((p for p in placed if p[2] == side), key=lambda p: p[0])
282
+ want = [p[0] for p in items]
283
+ for k in range(1, len(items)):
284
+ items[k][0] = max(items[k][0], items[k - 1][0] + gap)
285
+ for p, w in zip(items, want):
286
+ ty, tx, _, text, lead, (ex, ey) = p
287
+ if lead or abs(ty - w) > 0.5:
288
+ canvas.draw_lines([ex, tx - (2 if side == "left" else -2)], [ey, ty],
289
+ color=font.color, width=0.5)
290
+ draw_text(canvas, text, tx, ty, font, side, "center")
291
+
292
+ def legend_colors_at(self, ctx: Any, i: int) -> tuple[Any, Any]:
293
+ cols = self.wedge_colors(ctx)
294
+ st = self.stats
295
+ return (cols[st[i]["index"]] if i < len(st) else None), None
296
+
297
+ def legend_glyph_at(self, ctx: Any, i: int, x: float, y: float, w: float, h: float) -> None:
298
+ fill, _ = self.legend_colors_at(ctx, i)
299
+ if fill is not None:
300
+ ctx.canvas.fill_style = fill
301
+ ctx.canvas.fill_rect(x + (w - h) / 2, y, h, h)
302
+
303
+ def legend_glyph(self, ctx: Any, x: float, y: float, w: float, h: float) -> None:
304
+ self.legend_glyph_at(ctx, 0, x, y, w, h)