plot-io 0.12.0__py3-none-win_amd64.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 (130) hide show
  1. plot_io/__init__.py +73 -0
  2. plot_io/_methods.py +1691 -0
  3. plot_io/_native/__init__.py +199 -0
  4. plot_io/_native/__main__.py +197 -0
  5. plot_io/_native/_api.py +457 -0
  6. plot_io/_native/bin/windows-x86_64.dll +0 -0
  7. plot_io/_native/kernels.py +335 -0
  8. plot_io/_native/manifest.json +49 -0
  9. plot_io/axis.py +1347 -0
  10. plot_io/boxes.py +1156 -0
  11. plot_io/canvas.py +389 -0
  12. plot_io/color/__init__.py +3 -0
  13. plot_io/color/data.py +1646 -0
  14. plot_io/color/maps.py +922 -0
  15. plot_io/color/values.py +295 -0
  16. plot_io/color/variants.py +1246 -0
  17. plot_io/colorbar.py +1426 -0
  18. plot_io/columns.py +614 -0
  19. plot_io/decorations.py +920 -0
  20. plot_io/defaults.py +809 -0
  21. plot_io/density.py +113 -0
  22. plot_io/ext.py +68 -0
  23. plot_io/facets.py +240 -0
  24. plot_io/figure.py +788 -0
  25. plot_io/formatters.py +97 -0
  26. plot_io/genome/__init__.py +17 -0
  27. plot_io/genome/adapters.py +356 -0
  28. plot_io/genome/combine.py +123 -0
  29. plot_io/genome/contacts.py +508 -0
  30. plot_io/genome/intervals.py +750 -0
  31. plot_io/genome/lanes.py +191 -0
  32. plot_io/genome/layout.py +184 -0
  33. plot_io/genome/links.py +364 -0
  34. plot_io/genome/pack.py +27 -0
  35. plot_io/genome/readers.py +145 -0
  36. plot_io/genome/regions.py +228 -0
  37. plot_io/genome/scale.py +349 -0
  38. plot_io/genome/series.py +344 -0
  39. plot_io/genome/signal.py +621 -0
  40. plot_io/genome/sites.py +782 -0
  41. plot_io/genome/sources.py +459 -0
  42. plot_io/genome/testing.py +161 -0
  43. plot_io/geometry.py +289 -0
  44. plot_io/groups.py +308 -0
  45. plot_io/guides.py +503 -0
  46. plot_io/helpers/__init__.py +17 -0
  47. plot_io/helpers/clustermap.py +202 -0
  48. plot_io/helpers/dendrogram.py +172 -0
  49. plot_io/helpers/grids.py +304 -0
  50. plot_io/helpers/qq.py +334 -0
  51. plot_io/helpers/raincloud.py +249 -0
  52. plot_io/helpers/tree.py +214 -0
  53. plot_io/helpers/volcano.py +505 -0
  54. plot_io/hints.py +125 -0
  55. plot_io/kernels.py +740 -0
  56. plot_io/labels.py +566 -0
  57. plot_io/layers/__init__.py +100 -0
  58. plot_io/layers/base.py +630 -0
  59. plot_io/layers/context.py +87 -0
  60. plot_io/layers/core.py +446 -0
  61. plot_io/layers/geoms.py +1362 -0
  62. plot_io/layers/grammar.py +586 -0
  63. plot_io/layers/paint.py +614 -0
  64. plot_io/layers/pipeline.py +170 -0
  65. plot_io/layers/positions.py +431 -0
  66. plot_io/layers/stats.py +1088 -0
  67. plot_io/layout.py +1381 -0
  68. plot_io/legend.py +605 -0
  69. plot_io/levels.py +509 -0
  70. plot_io/limits.py +124 -0
  71. plot_io/lines.py +42 -0
  72. plot_io/mapped.py +522 -0
  73. plot_io/markers.py +110 -0
  74. plot_io/markup.py +128 -0
  75. plot_io/numbers.py +354 -0
  76. plot_io/occupancy.py +295 -0
  77. plot_io/plot.py +597 -0
  78. plot_io/presets/__init__.py +51 -0
  79. plot_io/presets/_util.py +153 -0
  80. plot_io/presets/area.py +145 -0
  81. plot_io/presets/bar.py +261 -0
  82. plot_io/presets/box.py +400 -0
  83. plot_io/presets/catpoints.py +177 -0
  84. plot_io/presets/contour.py +353 -0
  85. plot_io/presets/curves.py +30 -0
  86. plot_io/presets/dots.py +355 -0
  87. plot_io/presets/ecdf.py +97 -0
  88. plot_io/presets/errorbar.py +106 -0
  89. plot_io/presets/errors.py +296 -0
  90. plot_io/presets/fill.py +74 -0
  91. plot_io/presets/heatmap.py +322 -0
  92. plot_io/presets/hexbin.py +198 -0
  93. plot_io/presets/hist.py +391 -0
  94. plot_io/presets/hist2d.py +87 -0
  95. plot_io/presets/kde.py +162 -0
  96. plot_io/presets/kde2d.py +147 -0
  97. plot_io/presets/line.py +143 -0
  98. plot_io/presets/pie.py +502 -0
  99. plot_io/presets/regression.py +115 -0
  100. plot_io/presets/scatter.py +194 -0
  101. plot_io/presets/stem.py +100 -0
  102. plot_io/presets/violin.py +323 -0
  103. plot_io/py.typed +0 -0
  104. plot_io/registry.py +82 -0
  105. plot_io/render.py +480 -0
  106. plot_io/rows.py +491 -0
  107. plot_io/scales.py +103 -0
  108. plot_io/seams.py +138 -0
  109. plot_io/settings.py +807 -0
  110. plot_io/shapes.py +66 -0
  111. plot_io/sharing.py +232 -0
  112. plot_io/sizemaps.py +97 -0
  113. plot_io/spec.py +240 -0
  114. plot_io/states.py +216 -0
  115. plot_io/styles/__init__.py +307 -0
  116. plot_io/styles/library.py +397 -0
  117. plot_io/tables.py +138 -0
  118. plot_io/text.py +412 -0
  119. plot_io/ticks.py +400 -0
  120. plot_io/times.py +287 -0
  121. plot_io/titles.py +225 -0
  122. plot_io/transform.py +114 -0
  123. plot_io/types.py +13604 -0
  124. plot_io/units.py +768 -0
  125. plot_io/validators.py +1029 -0
  126. plot_io/values.py +190 -0
  127. plot_io-0.12.0.dist-info/METADATA +215 -0
  128. plot_io-0.12.0.dist-info/RECORD +130 -0
  129. plot_io-0.12.0.dist-info/WHEEL +4 -0
  130. plot_io-0.12.0.dist-info/licenses/LICENSE +28 -0
plot_io/decorations.py ADDED
@@ -0,0 +1,920 @@
1
+ """Decorations: reference lines, spans, text, annotations and brackets on a plot.
2
+
3
+ Decorations are not series: they take no color of the plot's, are in the legend only with a
4
+ ``name``, and change the axis limits only with ``extend_limits=True``. They are drawn
5
+ with the series in ``z`` order (spans under the series, lines and text over them by
6
+ default). Positions are in data units (``coords="data"``), fractions of the axes
7
+ (``"axes"``) or of the figure (``"figure"``).
8
+ """
9
+
10
+ import math
11
+ from typing import Any
12
+
13
+ from vec_io import Path
14
+
15
+ from .canvas import Canvas
16
+ from ._methods import AnnotateMixin, AxLineMixin, BracketMixin, HLineMixin, HSpanMixin, TextMixin, VLineMixin, VSpanMixin
17
+ from . import groups
18
+ from . import kernels
19
+ from .color.values import color
20
+
21
+ from .shapes import swatch
22
+ from .settings import Setting, Settings, boolean, either, literal, number, one_of, table, text
23
+ from .validators import angle, length
24
+ from .lines import dash_of
25
+ from .color.values import resolve_color, with_alpha
26
+ from .text import Font, Run, draw_run, font_of, lines_run
27
+ from .times import axis_value
28
+
29
+ __all__ = ["Decoration", "HLine", "VLine", "AxLine", "HSpan", "VSpan", "Text", "Annotate",
30
+ "Bracket"]
31
+
32
+
33
+
34
+ #: A position along an axis: a data value (bare numbers), or `%` of the axes / a length
35
+ #: from its start.
36
+ _AXES_POSITION = length(percent="axes_length", bare="data")
37
+
38
+
39
+ def _point(v: Any) -> tuple[Any, Any]:
40
+ if not isinstance(v, (tuple, list)) or len(v) != 2:
41
+ raise ValueError(f"expected a point (x, y), got {v!r}")
42
+ return v[0], v[1]
43
+
44
+
45
+ _COORDS = one_of("data", "axes", "figure")
46
+
47
+
48
+ def _arrow_style(v: Any) -> str:
49
+ if v not in ("->", "-|>", "-"):
50
+ raise ValueError(f"expected '->', '-|>' or '-', got {v!r}")
51
+ return v
52
+
53
+
54
+ def _common() -> dict[str, Setting]:
55
+ return dict(
56
+ name=Setting(text, doc="Legend entry; unset: not in the legend."),
57
+ opacity=Setting(number(0, 1), doc="Opacity."),
58
+ z=Setting(number(), doc="Drawing order among the series (default 0)."),
59
+ extend_limits=Setting(boolean, doc="Include the decoration in the axis limits."),
60
+ clip=Setting(boolean, doc="Clip to the axes area."),
61
+ )
62
+
63
+
64
+ def _line() -> dict[str, Setting]:
65
+ return dict(
66
+ **groups.Stroke.embed(only=("color", "line_width", "line_style"),
67
+ inherited=("line_width",)),
68
+ )
69
+
70
+
71
+ def _font() -> dict[str, Setting]:
72
+ return dict(
73
+ font_size=Setting(length(positive=True, font=True, percent="em"), inherited=True, doc="Font size."),
74
+ **groups.Font.embed(only=groups.Font.FACE),
75
+ **groups.TextStyle.embed(only=("font_weight", "font_style")),
76
+ text_color=Setting(color, inherited=True, doc="Text color."),
77
+ )
78
+
79
+
80
+ class Decoration(Settings):
81
+ """Base of decorations: behaves like a series towards the axes and the legend."""
82
+
83
+ AXES = True
84
+ DODGE_GROUP = None
85
+ STACK_GROUP = None
86
+ #: The axis roles the decoration's data coordinates are on.
87
+ ROLES: tuple[str, ...] = ()
88
+
89
+ def __init__(self, plot: Any, **settings: Any) -> None:
90
+ self.plot = plot
91
+ super().__init__(**settings)
92
+ plot.decorations.append(self)
93
+
94
+ def _parent(self) -> Settings:
95
+ return self.plot
96
+
97
+ def __repr__(self) -> str:
98
+ return f"{self.kind}()"
99
+
100
+ # -- what the axes need (only with extend_limits) -------------------------------
101
+
102
+ def has_axis(self, role: str) -> bool:
103
+ return role in self.ROLES and self.resolve("extend_limits")
104
+
105
+ def categories(self, role: str) -> list[Any] | None:
106
+ return None
107
+
108
+ def data_values(self, role: str) -> list[float]:
109
+ return []
110
+
111
+ def values(self, role: str) -> Any:
112
+ from array import array
113
+ out = array("d")
114
+ for v in self.data_values(role):
115
+ try:
116
+ out.append(axis_value(v))
117
+ except ValueError: # a category name
118
+ pass
119
+ return out
120
+
121
+ def extent(self, role: str, scale: Any) -> tuple[int, float, float]:
122
+ return kernels.extent(self.values(role), scale.function)
123
+
124
+ def inverts(self, role: str) -> bool:
125
+ return False
126
+
127
+ def zero_default(self, role: str) -> bool:
128
+ return False
129
+
130
+ def zero_padding(self, role: str) -> bool:
131
+ return False
132
+
133
+ def sticky(self, role: str) -> list[float]:
134
+ return []
135
+
136
+ def legend_labels(self) -> list[Any]:
137
+ """Legend entries of the object: its ``name``, or none."""
138
+ name = self.resolve("name")
139
+ return [] if name is None else [name]
140
+
141
+ def legend_glyph_at(self, ctx: Any, i: int, x: float, y: float, w: float, h: float) -> None:
142
+ """The symbol of legend entry ``i`` (series with several entries override this)."""
143
+ self.legend_glyph(ctx, x, y, w, h)
144
+
145
+ def legend_colors_at(self, ctx: Any, i: int) -> tuple[Any, Any]:
146
+ return self.legend_colors(ctx)
147
+
148
+ def legend_key_at(self, ctx: Any, i: int) -> Any:
149
+ """What the symbol of entry ``i`` draws, for comparing entries."""
150
+ c = with_alpha(ctx.color(self.resolve("color")), self.resolve("opacity"))
151
+ if "line_style" in self.SETTINGS:
152
+ lw = self.resolve("line_width")
153
+ return "line", c, lw, dash_of(self.resolve("line_style"), lw)
154
+ return "fill", c
155
+
156
+ def legend_colors(self, ctx: Any) -> tuple[Any, Any]:
157
+ c = with_alpha(ctx.color(self.resolve("color")), self.resolve("opacity"))
158
+ return c, None
159
+
160
+ def legend_glyph(self, ctx: Any, x: float, y: float, w: float, h: float) -> None:
161
+ c = with_alpha(ctx.color(self.resolve("color")), self.resolve("opacity"))
162
+ if c is None:
163
+ return
164
+ if "line_style" in self.SETTINGS:
165
+ lw = self.resolve("line_width")
166
+ ctx.canvas.draw_lines([x, x + w], [y + h / 2, y + h / 2], stroke=c, stroke_width=lw,
167
+ stroke_dasharray=dash_of(self.resolve("line_style"), lw))
168
+ else:
169
+ ctx.canvas.fill = c
170
+ ctx.canvas.draw_rect(*swatch(x, y, w, h), stroke=None)
171
+
172
+ # -- positions ------------------------------------------------------------------
173
+
174
+ def _pos(self, ctx: Any, role: str, v: Any, coords: str) -> float:
175
+ """Page coordinate of ``v`` along ``role`` (``x`` or ``y``)."""
176
+ x0, y0, x1, y1 = ctx.rect
177
+ if coords == "axes":
178
+ return x0 + float(v) * (x1 - x0) if role == "x" else y1 - float(v) * (y1 - y0)
179
+ if coords == "figure":
180
+ fw, fh = getattr(ctx, "figure_size", (x1, y1))
181
+ return float(v) * fw if role == "x" else (1.0 - float(v)) * fh
182
+ state = ctx.x if role == "x" else ctx.y
183
+ mapper = ctx.xmap if role == "x" else ctx.ymap
184
+ return mapper(state.value(v))
185
+
186
+ def _along(self, ctx: Any, name: str, role: str) -> float:
187
+ """Page coordinate of position setting ``name`` (``min``, ``max``) along ``role``: a
188
+ data value, or `%` of the axes (a length) from their left or bottom edge."""
189
+ x0, y0, x1, y1 = ctx.rect
190
+ raw = self._raw(name)
191
+ if isinstance(raw, (int, float)):
192
+ return self._pos(ctx, role, raw, "data")
193
+ px = self.resolve(name, of=(x1 - x0) if role == "x" else (y1 - y0))
194
+ return x0 + px if role == "x" else y1 - px
195
+
196
+ def draw(self, ctx: Any) -> None:
197
+ raise NotImplementedError
198
+
199
+ def occupy(self, fonts: Any, ctx: Any, occ: Any) -> None:
200
+ """Marks what it draws over the series on the occupancy grid ``occ`` (guides
201
+ placed inside the axes avoid it): text and lines; spans, drawn under the series,
202
+ mark nothing."""
203
+
204
+
205
+ class _Rule(Decoration):
206
+ """A horizontal or vertical reference line across part of the axes."""
207
+
208
+ VERTICAL = False
209
+ value: Any = None
210
+
211
+ def _ends(self, ctx: Any) -> tuple[list[float], list[float]] | None:
212
+ """The line's ends on the page (``None``: outside the scale's domain)."""
213
+ if self.VERTICAL:
214
+ p = self._pos(ctx, "x", self.value, "data")
215
+ xs, ys = [p, p], [self._along(ctx, "min", "y"), self._along(ctx, "max", "y")]
216
+ else:
217
+ p = self._pos(ctx, "y", self.value, "data")
218
+ xs, ys = [self._along(ctx, "min", "x"), self._along(ctx, "max", "x")], [p, p]
219
+ return (xs, ys) if all(math.isfinite(v) for v in xs + ys) else None
220
+
221
+ def occupy(self, fonts: Any, ctx: Any, occ: Any) -> None:
222
+ ends = self._ends(ctx)
223
+ if ends is not None:
224
+ occ.lines(*ends)
225
+
226
+ def draw(self, ctx: Any) -> None:
227
+ c = with_alpha(ctx.color(self.resolve("color")), self.resolve("opacity"))
228
+ lw = self.resolve("line_width")
229
+ ends = self._ends(ctx)
230
+ if c is None or ends is None:
231
+ return
232
+ xs, ys = ends
233
+ ctx.canvas.draw_lines(xs, ys, stroke=c, stroke_width=lw, stroke_dasharray=dash_of(self.resolve("line_style"), lw),
234
+ stroke_linecap="butt")
235
+
236
+ def data_values(self, role: str) -> list[float]:
237
+ return [self.value]
238
+
239
+
240
+ class HLine(HLineMixin, _Rule):
241
+ """A horizontal line at ``y`` from ``min`` to ``max`` (data values, or `%` of the axes
242
+ width).
243
+
244
+ Decorations (lines, spans, text, annotations, brackets) take no color of the plot's, are in
245
+ the legend only with a ``name``, and change the limits only with
246
+ ``extend_limits=True``. Spans are drawn under the series (``z=-1``), lines and text over
247
+ them (``z=1``). Positions are axis values (numbers, dates, ISO 8601 text, category
248
+ names), or fractions with ``coords="axes"`` or ``"figure"``.
249
+ """
250
+
251
+ SETTINGS = table(
252
+ **_line(),
253
+ min=Setting(_AXES_POSITION, doc="Start: a data value, or `%` (or a length) "
254
+ "from the start of the axes."),
255
+ max=Setting(_AXES_POSITION, doc="End: a data value, or `%` (or a length) from "
256
+ "the start of the axes."),
257
+ **_common(),
258
+ )
259
+ ROLES = ("y",)
260
+
261
+ def __init__(self, plot: Any, y: Any, **settings: Any) -> None:
262
+ self.value = y
263
+ super().__init__(plot, **settings)
264
+
265
+
266
+ class VLine(VLineMixin, _Rule):
267
+ """A vertical line at ``x`` from ``min`` to ``max`` (data values, or `%` of the axes
268
+ height)."""
269
+
270
+ SETTINGS = HLine.SETTINGS
271
+ ROLES = ("x",)
272
+ VERTICAL = True
273
+
274
+ def __init__(self, plot: Any, x: Any, **settings: Any) -> None:
275
+ self.value = x
276
+ super().__init__(plot, **settings)
277
+
278
+
279
+ class AxLine(AxLineMixin, Decoration):
280
+ """An infinite straight line through ``point`` and ``point2``, or with ``slope`` (data
281
+ units; linear axes)."""
282
+
283
+ SETTINGS = table(**_line(), **_common())
284
+ ROLES = ("x", "y")
285
+
286
+ def __init__(self, plot: Any, point: Any, slope: Any = None, point2: Any = None,
287
+ **settings: Any) -> None:
288
+ self.p1 = _point(point)
289
+ if (slope is None) == (point2 is None):
290
+ raise ValueError("give either slope or point2")
291
+ self.p2 = _point(point2) if point2 is not None else None
292
+ self.slope = None if slope is None else float(slope)
293
+ super().__init__(plot, **settings)
294
+
295
+ def data_values(self, role: str) -> list[float]:
296
+ i = 0 if role == "x" else 1
297
+ pts = [self.p1] + ([self.p2] if self.p2 is not None else [])
298
+ return [p[i] for p in pts]
299
+
300
+ def draw(self, ctx: Any) -> None:
301
+ c = with_alpha(ctx.color(self.resolve("color")), self.resolve("opacity"))
302
+ lw = self.resolve("line_width")
303
+ if c is None:
304
+ return
305
+ if self.p2 is None:
306
+ for st in (ctx.x, ctx.y):
307
+ if st.scale is None or st.scale.name != "linear":
308
+ raise ValueError("axline with a slope needs linear axes; give point2")
309
+ p2 = (float(self.p1[0]) + 1.0, float(self.p1[1]) + float(self.slope or 0.0))
310
+ else:
311
+ p2 = self.p2
312
+ ax, ay = self._pos(ctx, "x", self.p1[0], "data"), self._pos(ctx, "y", self.p1[1], "data")
313
+ bx, by = self._pos(ctx, "x", p2[0], "data"), self._pos(ctx, "y", p2[1], "data")
314
+ dx, dy = bx - ax, by - ay
315
+ n = math.hypot(dx, dy)
316
+ if not n or not math.isfinite(n):
317
+ return
318
+ x0, y0, x1, y1 = ctx.rect
319
+ far = 2.0 * (math.hypot(x1 - x0, y1 - y0) + abs(ax - x0) + abs(ay - y0)) / n
320
+ ctx.canvas.draw_lines([ax - far * dx, ax + far * dx], [ay - far * dy, ay + far * dy],
321
+ stroke=c, stroke_width=lw, stroke_dasharray=dash_of(self.resolve("line_style"), lw))
322
+
323
+
324
+ class _Span(Decoration):
325
+ VERTICAL = False
326
+ v0: Any = None
327
+ v1: Any = None
328
+
329
+ def draw(self, ctx: Any) -> None:
330
+ c = with_alpha(ctx.color(self.resolve("color")), self.resolve("opacity"))
331
+ if c is None:
332
+ return
333
+ if self.VERTICAL:
334
+ a, b = self._end(ctx, "x", self.v0), self._end(ctx, "x", self.v1)
335
+ lo, hi = self._along(ctx, "min", "y"), self._along(ctx, "max", "y")
336
+ r = (min(a, b), min(lo, hi), abs(b - a), abs(lo - hi))
337
+ else:
338
+ a, b = self._end(ctx, "y", self.v0), self._end(ctx, "y", self.v1)
339
+ lo, hi = self._along(ctx, "min", "x"), self._along(ctx, "max", "x")
340
+ r = (min(lo, hi), min(a, b), abs(hi - lo), abs(b - a))
341
+ if not all(math.isfinite(v) for v in r):
342
+ return
343
+ canvas = ctx.canvas
344
+ path = Path().rect(*r)
345
+ canvas.draw_path(path, fill=c, stroke=None)
346
+ lc = ctx.color(self.resolve("edge_color"))
347
+ if lc is not None and self.resolve("edge_width"):
348
+ canvas.stroke_width = self.resolve("edge_width")
349
+ canvas.draw_path(path, fill=None, stroke=lc)
350
+
351
+ def _end(self, ctx: Any, role: str, v: Any) -> float:
352
+ """Page coordinate of an end of the span: outside the scale's domain (0 on a log
353
+ axis), the axes edge on that side."""
354
+ p = self._pos(ctx, role, v, "data")
355
+ if math.isfinite(p):
356
+ return p
357
+ x0, y0, x1, y1 = ctx.rect
358
+ a, b = (x0, x1) if role == "x" else (y0, y1)
359
+ if math.isnan(p): # below the domain: the low limit's edge
360
+ state = ctx.x if role == "x" else ctx.y
361
+ low = (ctx.xmap if role == "x" else ctx.ymap)(state.lo)
362
+ return a if abs(low - a) <= abs(low - b) else b
363
+ return a if p < a else b
364
+
365
+ def data_values(self, role: str) -> list[float]:
366
+ return [self.v0, self.v1]
367
+
368
+
369
+ class HSpan(HSpanMixin, _Span):
370
+ """A horizontal band from ``y0`` to ``y1``, from ``min`` to ``max`` of the axes width."""
371
+
372
+ SETTINGS = table(
373
+ color=Setting(color, doc="Fill color."),
374
+ **groups.Stroke.embed("edge_",
375
+ only=("color", "line_width"),
376
+ docs={"color": "Outline color; default none.",
377
+ "line_width": "Outline width."}),
378
+ min=Setting(_AXES_POSITION, doc="Start: a data value, or `%` (or a length) "
379
+ "from the start of the axes."),
380
+ max=Setting(_AXES_POSITION, doc="End: a data value, or `%` (or a length) from "
381
+ "the start of the axes."),
382
+ **_common(),
383
+ )
384
+ ROLES = ("y",)
385
+
386
+ def __init__(self, plot: Any, y0: Any, y1: Any, **settings: Any) -> None:
387
+ self.v0, self.v1 = y0, y1
388
+ super().__init__(plot, **settings)
389
+
390
+
391
+ class VSpan(VSpanMixin, _Span):
392
+ """A vertical band from ``x0`` to ``x1``, from ``min`` to ``max`` of the axes height."""
393
+
394
+ SETTINGS = HSpan.SETTINGS
395
+ ROLES = ("x",)
396
+ VERTICAL = True
397
+
398
+ def __init__(self, plot: Any, x0: Any, x1: Any, **settings: Any) -> None:
399
+ self.v0, self.v1 = x0, x1
400
+ super().__init__(plot, **settings)
401
+
402
+
403
+ class Text(TextMixin, Decoration):
404
+ """Text at ``(x, y)``: a string or rich content.
405
+
406
+ A line break starts a line; ``rotation`` turns the text about its point. Text past the
407
+ axes (unclipped) widens the plot's margins; it is never moved.
408
+ """
409
+
410
+ SETTINGS = table(
411
+ align=Setting(one_of("left", "center", "right"),
412
+ doc="Horizontal alignment on the point."),
413
+ valign=Setting(one_of("baseline", "top", "center", "bottom"),
414
+ doc="Vertical alignment on the point."),
415
+ rotation=Setting(angle(), doc="Rotation (degrees, counterclockwise)."),
416
+ coords=Setting(_COORDS, doc="`data`, `axes` (0–1) or `figure` (0–1)."),
417
+ **groups.TextStyle.embed(only=("color",),
418
+ docs={"color": "Text color; default `text_color`."}),
419
+ **_font(),
420
+ **{k: v for k, v in _common().items() if k != "clip"},
421
+ clip=Setting(boolean, doc="Clip to the axes area."),
422
+ )
423
+ ROLES = ("x", "y")
424
+
425
+ def __init__(self, plot: Any, x: Any, y: Any, content: Any, **settings: Any) -> None:
426
+ self.x, self.y, self.content = x, y, content
427
+ super().__init__(plot, **settings)
428
+
429
+ def has_axis(self, role: str) -> bool:
430
+ return super().has_axis(role) and self.resolve("coords") == "data"
431
+
432
+ def data_values(self, role: str) -> list[float]:
433
+ return [self.x if role == "x" else self.y]
434
+
435
+ def font(self) -> Font:
436
+ c = self.resolve("color")
437
+ return font_of(self, self.resolve("font_size"), self.resolve("font_weight"),
438
+ self.resolve("font_style"), c)
439
+
440
+ def legend_colors(self, ctx: Any) -> tuple[Any, Any]:
441
+ return ctx.color(self.resolve("color")), None
442
+
443
+ def _at(self, ctx: Any) -> tuple[float, float]:
444
+ """The page point the text is anchored at."""
445
+ coords = self.resolve("coords")
446
+ return self._pos(ctx, "x", self.x, coords), self._pos(ctx, "y", self.y, coords)
447
+
448
+ def _rotation(self) -> float:
449
+ return float(self.resolve("rotation"))
450
+
451
+ def text_run(self, fonts: Any, ctx: Any) -> "Run | tuple[Run, ...] | None":
452
+ """The text as drawn on the axes of ``ctx``: a run per line (lines split at
453
+ ``\\n``; ``None``: nothing drawn, its point outside the scale's domain or no
454
+ content)."""
455
+ px, py = self._at(ctx)
456
+ if not (math.isfinite(px) and math.isfinite(py)):
457
+ return None
458
+ font = self.font()
459
+ font.color = with_alpha(resolve_color(font.color, ctx.cycle), self.resolve("opacity"))
460
+ return lines_run(fonts, self.content, px, py, font, self.resolve("align"),
461
+ self.resolve("valign"), self._rotation())
462
+
463
+ def _corners(self, fonts: Any, ctx: Any) -> tuple[list[float], list[float]] | None:
464
+ """The corners of the drawn text's (rotated) box on the page."""
465
+ from .labels import run_quad
466
+ q = run_quad(fonts, self.text_run(fonts, ctx))
467
+ if q is None:
468
+ return None
469
+ return [p[0] for p in q], [p[1] for p in q]
470
+
471
+ def occupy(self, fonts: Any, ctx: Any, occ: Any) -> None:
472
+ got = self._corners(fonts, ctx)
473
+ if got is not None:
474
+ xs, ys = got
475
+ occ.rects([min(xs)], [min(ys)], [max(xs)], [max(ys)], solid=True)
476
+
477
+ def reach(self, fonts: Any, ctx: Any) -> tuple[float, float, float, float]:
478
+ """How far the text drawn on the axes of ``ctx`` passes them on the left, right,
479
+ top and bottom (px; 0 within)."""
480
+ got = self._corners(fonts, ctx)
481
+ if got is None:
482
+ return 0.0, 0.0, 0.0, 0.0
483
+ xs, ys = got
484
+ x0, y0, x1, y1 = ctx.rect
485
+ return (max(0.0, x0 - min(xs)), max(0.0, max(xs) - x1), max(0.0, y0 - min(ys)),
486
+ max(0.0, max(ys) - y1))
487
+
488
+ def counts(self) -> bool:
489
+ """Whether the margins make room for the text: drawn unclipped, placed by the axes
490
+ (data or axes coordinates; text placed on the figure has no plot to grow)."""
491
+ return not self.resolve("clip") and self.resolve("coords") != "figure"
492
+
493
+ def draw(self, ctx: Any) -> None:
494
+ draw_run(ctx.canvas, self.text_run(ctx.canvas, ctx))
495
+
496
+
497
+ class Annotate(AnnotateMixin, Text):
498
+ """Text at ``xytext`` with an arrow to ``xy``.
499
+
500
+ ``text_coords="offset"`` gives ``xytext`` in points from ``xy`` (y up). The arrow runs
501
+ from the edge of the text box to ``xy``, ``shrink`` points short of both.
502
+ """
503
+
504
+ SETTINGS = table(
505
+ **{k: v for k, v in Text.SETTINGS.items() if k not in ("align", "valign")},
506
+ align=Setting(one_of("left", "center", "right"),
507
+ doc="Horizontal alignment on `xytext`."),
508
+ valign=Setting(one_of("baseline", "top", "center", "bottom"),
509
+ doc="Vertical alignment on `xytext`."),
510
+ text_coords=Setting(one_of("data", "axes", "figure", "offset"),
511
+ doc="Coordinates of `xytext`: as `coords`, or `offset` (px from "
512
+ "`xy`, y up); default `coords`."),
513
+ arrow=Setting(boolean, doc="Draw an arrow from the text to `xy`."),
514
+ arrow_style=Setting(_arrow_style,
515
+ doc="`->` (open head), `-|>` (filled head) or `-` (no head)."),
516
+ **groups.Stroke.embed("arrow_",
517
+ only=("color", "line_width"),
518
+ docs={"color": "Arrow color; default the text color.",
519
+ "line_width": "Arrow line width."}),
520
+ head_length=Setting(length(lo=0), doc="Arrowhead length."),
521
+ shrink=Setting(length(lo=0), doc="Gap left at both ends of the arrow."),
522
+ )
523
+
524
+ def __init__(self, plot: Any, content: Any, xy: Any, xytext: Any = None,
525
+ **settings: Any) -> None:
526
+ self.xy = _point(xy)
527
+ self.xytext = _point(xytext) if xytext is not None else None
528
+ x, y = self.xytext if self.xytext is not None else self.xy
529
+ super().__init__(plot, x, y, content, **settings)
530
+
531
+ def data_values(self, role: str) -> list[float]:
532
+ i = 0 if role == "x" else 1
533
+ out = [self.xy[i]]
534
+ if self.xytext is not None and self.resolve("text_coords") == "data":
535
+ out.append(self.xytext[i])
536
+ return out
537
+
538
+ def _point(self, ctx: Any) -> tuple[float, float]:
539
+ """The page point the arrow points to (``xy``)."""
540
+ coords = self.resolve("coords")
541
+ return self._pos(ctx, "x", self.xy[0], coords), self._pos(ctx, "y", self.xy[1], coords)
542
+
543
+ def _at(self, ctx: Any) -> tuple[float, float]:
544
+ ax, ay = self._point(ctx)
545
+ tx = self.resolve("text_coords")
546
+ if self.xytext is None:
547
+ return ax, ay
548
+ if tx == "offset":
549
+ return ax + float(self.xytext[0]), ay - float(self.xytext[1])
550
+ return self._pos(ctx, "x", self.xytext[0], tx), self._pos(ctx, "y", self.xytext[1], tx)
551
+
552
+ def counts(self) -> bool:
553
+ tc = self.resolve("text_coords")
554
+ return not self.resolve("clip") and (
555
+ tc != "figure" if self.xytext is not None and tc != "offset"
556
+ else self.resolve("coords") != "figure")
557
+
558
+ def draw(self, ctx: Any) -> None:
559
+ canvas = ctx.canvas
560
+ px, py = self._at(ctx)
561
+ if not (math.isfinite(px) and math.isfinite(py)):
562
+ return # outside the scale's domain
563
+ ax, ay = self._point(ctx)
564
+ draw_run(canvas, self.text_run(canvas, ctx))
565
+ got = self._corners(canvas, ctx)
566
+ box = (min(got[0]), min(got[1]), max(got[0]), max(got[1])) if got else (px, py, px, py)
567
+ if self.resolve("arrow") and self.xytext is not None and math.isfinite(ax) \
568
+ and math.isfinite(ay):
569
+ ac = with_alpha(ctx.color(self.resolve("arrow_color")), self.resolve("opacity"))
570
+ self._arrow(canvas, box, ax, ay, ac)
571
+
572
+ def _arrow(self, canvas: Canvas, box: tuple[float, float, float, float], ax: float, ay: float,
573
+ c: Any) -> None:
574
+ x0, y0, x1, y1 = box
575
+ cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
576
+ dx, dy = ax - cx, ay - cy
577
+ n = math.hypot(dx, dy)
578
+ if not n or c is None:
579
+ return
580
+ ux, uy = dx / n, dy / n
581
+ # leave the text box: smallest t where the ray crosses its edge
582
+ ts = []
583
+ if ux:
584
+ ts.append(((x1 if ux > 0 else x0) - cx) / ux)
585
+ if uy:
586
+ ts.append(((y1 if uy > 0 else y0) - cy) / uy)
587
+ t0 = min(t for t in ts if t >= 0) if ts else 0.0
588
+ shrink = self.resolve("shrink")
589
+ t0 += shrink
590
+ t1 = n - shrink
591
+ if t1 <= t0:
592
+ return
593
+ sx, sy = cx + ux * t0, cy + uy * t0
594
+ ex, ey = cx + ux * t1, cy + uy * t1
595
+ lw = self.resolve("arrow_width")
596
+ style = self.resolve("arrow_style")
597
+ hs = self.resolve("head_length")
598
+ if style == "-|>":
599
+ ex2, ey2 = ex - ux * hs * 0.9, ey - uy * hs * 0.9
600
+ else:
601
+ ex2, ey2 = ex, ey
602
+ canvas.draw_lines([sx, ex2], [sy, ey2], stroke=c, stroke_width=lw, stroke_linecap="butt")
603
+ if style == "-" or not hs:
604
+ return
605
+ hw = 0.45 * hs
606
+ px_, py_ = -uy, ux
607
+ bx, by = ex - ux * hs, ey - uy * hs
608
+ with canvas.state():
609
+ path = Path().move_to(bx + px_ * hw, by + py_ * hw).line_to(ex, ey)
610
+ path.line_to(bx - px_ * hw, by - py_ * hw)
611
+ if style == "-|>":
612
+ canvas.draw_path(path.close(), fill=c, stroke=None)
613
+ else:
614
+ canvas.stroke_width = lw
615
+ canvas.stroke_linejoin = "miter"
616
+ canvas.draw_path(path, fill=None, stroke=c)
617
+
618
+
619
+ # -- brackets ----------------------------------------------------------------------------
620
+
621
+ def _stars(v: Any) -> tuple[float, ...]:
622
+ out = tuple(float(number(0, 1)(x)) for x in v)
623
+ if not out or any(a <= b for a, b in zip(out, out[1:])):
624
+ raise ValueError(f"expected decreasing thresholds in (0, 1], got {v!r}")
625
+ return out
626
+
627
+
628
+ def _p_format(v: Any) -> Any:
629
+ if v in ("stars", "p"):
630
+ return v
631
+ if isinstance(v, str) and "{" in v:
632
+ try:
633
+ v.format(p=0.5)
634
+ except (IndexError, KeyError, ValueError) as e:
635
+ raise ValueError(f"invalid format string {v!r}: {e}") from None
636
+ return v
637
+ raise ValueError(f"expected 'stars', 'p' or a format string with {{p}}, got {v!r}")
638
+
639
+
640
+ class Bracket(BracketMixin, Decoration):
641
+ """A line from ``a`` to ``b`` (categories or positions on the category axis) with tips
642
+ toward the data and text beyond it: ``text``, or ``p`` written as ``format`` says.
643
+ Unset ``y``: ``step`` above the highest value the plot's series draw between ``a`` and
644
+ ``b``, and ``step`` plus the text (and ``gap``) above every bracket of the plot it
645
+ overlaps (shorter brackets are placed first). Categories on y: the bracket stands
646
+ right of the data, its text rotated.
647
+
648
+ ``format``: ``"stars"`` (``*`` to ``****`` at p ≤ 0.05, 0.01, 0.001, 0.0001: ``stars``;
649
+ else ``ns``), ``"p"`` (``p = 0.003``, ``p < 0.0001``, in the plot's number format) or a
650
+ format string (``"{p:.2g}"``). plot_io computes no test: the caller gives p. Tips stop
651
+ short of the data or of the bracket under each end. Brackets are in the axis limits and
652
+ their text widens the margins. ``plot.brackets(pairs, p=…)`` adds one per pair; on a
653
+ facet set, ``panels.bracket`` and ``panels.brackets`` add them to every panel.
654
+ """
655
+
656
+ SETTINGS = table(
657
+ **_line(),
658
+ y=Setting(either(literal(None), number()),
659
+ doc="Value of the line on the value axis; unset: placed above the data."),
660
+ format=Setting(_p_format,
661
+ doc="Text of `p`: `stars` (`*` … `****` at each of `stars`, else "
662
+ "`ns`), `p` (`p = 0.003`, `p < 0.0001`) or a format string "
663
+ "(`\"{p:.2g}\"`)."),
664
+ stars=Setting(_stars, doc="Thresholds of `*`, `**`, `***`, `****` (p at most each)."),
665
+ tip=Setting(length(lo=0, percent="em"), doc="Length of the tips toward the data."),
666
+ step=Setting(length(lo=0, percent="axes_length", bare="data"),
667
+ doc="Distance above the data and between tiers: `%` of the data's "
668
+ "range on the value axis, or bare numbers in its units."),
669
+ gap=Setting(length(lo=0, percent="em"), doc="Gap between the line and the text."),
670
+ **_font(),
671
+ **_common(),
672
+ )
673
+
674
+ def __init__(self, plot: Any, a: Any, b: Any, text: Any = None, p: Any = None,
675
+ **settings: Any) -> None:
676
+ self.a, self.b, self.text, self.p = a, b, text, p
677
+ if p is not None:
678
+ p = float(p)
679
+ if not 0.0 <= p <= 1.0:
680
+ raise ValueError(f"bracket: p must be in [0, 1], got {p!r}")
681
+ self.p = p
682
+ super().__init__(plot, **settings)
683
+
684
+ def content(self) -> Any:
685
+ """The text drawn (``None``: none)."""
686
+ if self.text is not None or self.p is None:
687
+ return self.text
688
+ fmt, p = self.resolve("format"), self.p
689
+ if fmt == "stars":
690
+ n = sum(p <= t for t in self.resolve("stars"))
691
+ return "*" * n if n else "ns"
692
+ if fmt == "p":
693
+ from .numbers import NumberStyle, _restyle
694
+ style = NumberStyle.of(self.plot)
695
+ low = min(self.resolve("stars"))
696
+ if p < low:
697
+ return "p < " + _restyle(format(low, "g"), style)
698
+ return "p = " + _restyle(format(p, ".2g"), style)
699
+ return fmt.format(p=p)
700
+
701
+ # -- placement (all brackets of the plot together) -------------------------------
702
+
703
+ def _placed(self, scale: Any) -> tuple[str, float, float, float, float, float]:
704
+ """``(value role, position of a, of b, line, floor at a, floor at b)``: the line
705
+ and the floors under its ends (the data or lower brackets) in the scale's space."""
706
+ return _layout(self.plot, scale)[id(self)][:6]
707
+
708
+ def _clear(self, fonts: Any) -> float:
709
+ """How far (px) a bracket above this one passes its line: its text and ``gap``."""
710
+ content = self.content()
711
+ if content is None or content == "":
712
+ return 0.0
713
+ from .labels import quad
714
+ font = font_of(self, self.resolve("font_size"), self.resolve("font_weight"),
715
+ self.resolve("font_style"), self.resolve("text_color"))
716
+ q = quad(fonts, content, font, 0.0, 0.0, "center", "bottom")
717
+ return 0.0 if q is None else (max(p[1] for p in q) - min(p[1] for p in q)
718
+ + self.resolve("gap"))
719
+
720
+ def _lifts(self, scale: Any, fonts: Any) -> tuple[float, float, float]:
721
+ """How far (px) the line and the floors at ``a`` and ``b`` lie past their places
722
+ in the scale's space: each automatic line clears the text of the brackets under
723
+ it."""
724
+ got = _layout(self.plot, scale)
725
+ lift: dict[int, float] = {}
726
+ for k in sorted(got, key=lambda k: got[k][3]):
727
+ lift[k] = max((lift[q] + got[q][8]._clear(fonts) for q in got[k][6]),
728
+ default=0.0)
729
+ fa, fb = got[id(self)][7]
730
+ return (lift[id(self)], 0.0 if fa is None else lift[fa],
731
+ 0.0 if fb is None else lift[fb])
732
+
733
+ def label_room(self, fonts: Any, role: str) -> list[tuple[float, float, int]]:
734
+ """The room (px) the axis keeps past the lines that clear lower brackets' text."""
735
+ got = self.plot.__dict__.get("_brackets")
736
+ if got is None or id(self) not in got[2] or role != got[2][id(self)][0]:
737
+ return []
738
+ lift = self._lifts(got[3], fonts)[0]
739
+ v = got[2][id(self)][3]
740
+ if got[3] is not None and got[3].function:
741
+ v = got[3].inverse(v)
742
+ return [(v, lift, 1)] if lift > 0 and math.isfinite(v) else []
743
+
744
+ def has_axis(self, role: str) -> bool:
745
+ return self.resolve("extend_limits") and role == _orient(self.plot)[0]
746
+
747
+ def extent(self, role: str, scale: Any) -> tuple[int, float, float]:
748
+ line = self._placed(scale)[3]
749
+ v = scale.inverse(line) if scale.function else line
750
+ return (1, v, v) if math.isfinite(v) else (0, math.nan, math.nan)
751
+
752
+ def _geometry(self, ctx: Any, fonts: Any = None
753
+ ) -> tuple[list[float], list[float], float, float, str] | None:
754
+ """The polyline (tips and line) on the page, the text's anchor and the value role
755
+ (text measured with ``fonts``, else the context's canvas)."""
756
+ vrole = _orient(self.plot)[0]
757
+ crole = "x" if vrole == "y" else "y"
758
+ st = ctx.y if vrole == "y" else ctx.x
759
+ sc = st.scale
760
+ _, pa, pb, line, fa, fb = self._placed(sc)
761
+ vmap = ctx.ymap if vrole == "y" else ctx.xmap
762
+ up = -1.0 if vrole == "y" else 1.0 # the page direction away from the data
763
+ lifts = self._lifts(sc, ctx.canvas if fonts is None else fonts)
764
+
765
+ def page(f: float, lift: float) -> float:
766
+ return vmap(sc.inverse(f) if sc is not None and sc.function else f) + up * lift
767
+ a, b = self._pos(ctx, crole, pa, "data"), self._pos(ctx, crole, pb, "data")
768
+ lv = page(line, lifts[0])
769
+ if not all(math.isfinite(v) for v in (a, b, lv)):
770
+ return None
771
+ tip = self.resolve("tip")
772
+ tips = []
773
+ for f, lift in ((fa, lifts[1]), (fb, lifts[2])):
774
+ room = abs(page(f, lift) - lv) / 2 if math.isfinite(f) else tip
775
+ tips.append(lv - up * min(tip, room if math.isfinite(room) else tip))
776
+ us, vs = [a, a, b, b], [tips[0], lv, lv, tips[1]]
777
+ gap = self.resolve("gap")
778
+ if vrole == "y":
779
+ return us, vs, (a + b) / 2, lv + up * gap, vrole
780
+ return vs, us, lv + up * gap, (a + b) / 2, vrole
781
+
782
+ def text_run(self, fonts: Any, ctx: Any) -> Any:
783
+ g = self._geometry(ctx, fonts)
784
+ content = self.content()
785
+ if g is None or content is None or content == "":
786
+ return None
787
+ font = font_of(self, self.resolve("font_size"), self.resolve("font_weight"),
788
+ self.resolve("font_style"), self.resolve("text_color"))
789
+ font.color = with_alpha(resolve_color(font.color, ctx.cycle), self.resolve("opacity"))
790
+ if g[4] == "y":
791
+ return lines_run(fonts, content, g[2], g[3], font, "center", "bottom", 0.0)
792
+ return lines_run(fonts, content, g[2], g[3], font, "center", "bottom", -90.0)
793
+
794
+ def _corners(self, fonts: Any, ctx: Any) -> tuple[list[float], list[float]] | None:
795
+ """The corners of the drawn text's (rotated) box on the page."""
796
+ from .labels import run_quad
797
+ q = run_quad(fonts, self.text_run(fonts, ctx))
798
+ return None if q is None else ([p[0] for p in q], [p[1] for p in q])
799
+
800
+ def reach(self, fonts: Any, ctx: Any) -> tuple[float, float, float, float]:
801
+ """How far the text passes the axes on the left, right, top and bottom (px)."""
802
+ return Text.reach(self, fonts, ctx) # type: ignore[arg-type]
803
+
804
+ def counts(self) -> bool:
805
+ return not self.resolve("clip")
806
+
807
+ def occupy(self, fonts: Any, ctx: Any, occ: Any) -> None:
808
+ g = self._geometry(ctx, fonts)
809
+ if g is not None:
810
+ occ.lines(g[0], g[1])
811
+ got = self._corners(fonts, ctx)
812
+ if got is not None:
813
+ occ.rects([min(got[0])], [min(got[1])], [max(got[0])], [max(got[1])], solid=True)
814
+
815
+ def draw(self, ctx: Any) -> None:
816
+ g = self._geometry(ctx)
817
+ c = with_alpha(ctx.color(self.resolve("color")), self.resolve("opacity"))
818
+ if g is not None and c is not None:
819
+ lw = self.resolve("line_width")
820
+ ctx.canvas.draw_lines(g[0], g[1], stroke=c, stroke_width=lw,
821
+ stroke_dasharray=dash_of(self.resolve("line_style"), lw),
822
+ stroke_linecap="butt", stroke_linejoin="miter")
823
+ draw_run(ctx.canvas, self.text_run(ctx.canvas, ctx))
824
+
825
+
826
+ def _orient(plot: Any) -> tuple[str, dict[str, list[Any]]]:
827
+ """The value role of the plot's brackets (``"x"`` when its categories are on y or its
828
+ series are horizontal) and the categories of each axis."""
829
+ from .layers.grammar import flipped
830
+ from .layers.pipeline import plot_calls
831
+ cats: dict[str, list[Any]] = {"x": [], "y": []}
832
+ horizontal = False
833
+ for c in plot_calls(plot):
834
+ for r in ("x", "y"):
835
+ for k in c.categories(r) or ():
836
+ if k not in cats[r]:
837
+ cats[r].append(k)
838
+ horizontal = horizontal or flipped(c.series)
839
+ return ("x" if horizontal or (cats["y"] and not cats["x"]) else "y"), cats
840
+
841
+
842
+ def _layout(plot: Any, scale: Any) -> dict[int, tuple[Any, ...]]:
843
+ """Every bracket of ``plot`` placed (see :meth:`Bracket._placed`); cached per set of
844
+ calls and scale."""
845
+ from .layers.grammar import top
846
+ from .layers.pipeline import plot_calls
847
+ calls = plot_calls(plot)
848
+ key = (scale.name if scale is not None else None,
849
+ tuple(sorted(scale.options.items())) if scale is not None else ())
850
+ got = plot.__dict__.get("_brackets")
851
+ if got is not None and got[0] is calls and got[1] == key:
852
+ return got[2]
853
+ vrole, cats = _orient(plot)
854
+ crole = "x" if vrole == "y" else "y"
855
+ names = cats[crole]
856
+ half = 0.5 if names else 0.0
857
+ fn = scale.f if scale is not None and scale.function else (lambda v: v)
858
+
859
+ def pos(v: Any) -> float:
860
+ if names and not isinstance(v, (int, float)) and v in names:
861
+ return float(names.index(v))
862
+ return float(axis_value(v))
863
+
864
+ def high(lo: float, hi: float) -> float:
865
+ best = -math.inf
866
+ for c in calls:
867
+ v = top(c, lo, hi, vrole, cats)
868
+ if math.isfinite(v):
869
+ f = fn(v)
870
+ if math.isfinite(f):
871
+ best = max(best, f)
872
+ return best
873
+ lo_f, hi_f = math.inf, -math.inf
874
+ for c in calls:
875
+ n, a, b = c.extent(vrole, scale) if scale is not None else (0, 0, 0)
876
+ if n:
877
+ for v in (fn(a), fn(b)):
878
+ if math.isfinite(v):
879
+ lo_f, hi_f = min(lo_f, v), max(hi_f, v)
880
+ span = hi_f - lo_f if hi_f > lo_f else 1.0
881
+ brs = [d for d in plot.decorations if isinstance(d, Bracket)]
882
+ ends = {id(d): sorted((pos(d.a), pos(d.b))) for d in brs}
883
+ lines: dict[int, float] = {}
884
+ for d in brs:
885
+ y = d.resolve("y")
886
+ if y is not None:
887
+ lines[id(d)] = fn(float(y))
888
+ auto = sorted((d for d in brs if id(d) not in lines),
889
+ key=lambda d: ends[id(d)][1] - ends[id(d)][0])
890
+ for d in auto:
891
+ a, b = ends[id(d)]
892
+ raw = d._raw("step")
893
+ step = float(raw) if isinstance(raw, (int, float)) else d.resolve("step", of=span)
894
+ base = high(a - half, b + half)
895
+ line = (base if math.isfinite(base) else (hi_f if math.isfinite(hi_f) else 0.0)) + step
896
+ for q, ql in lines.items():
897
+ qa, qb = ends[q]
898
+ if qa <= b and a <= qb:
899
+ line = max(line, ql + step)
900
+ lines[id(d)] = line
901
+ out = {}
902
+ placed = {id(d) for d in auto}
903
+ for d in brs:
904
+ a, b = ends[id(d)]
905
+ floors: list[float] = []
906
+ sources: list[int | None] = []
907
+ for e in (a, b):
908
+ f, src = high(e - half, e + half), None
909
+ for q, ql in lines.items():
910
+ qa, qb = ends[q]
911
+ if q != id(d) and qa <= e <= qb and ql < lines[id(d)] and ql > f:
912
+ f, src = ql, q
913
+ floors.append(f)
914
+ sources.append(src)
915
+ under = tuple(q for q, ql in lines.items() if id(d) in placed and q != id(d)
916
+ and ends[q][0] <= b and a <= ends[q][1] and ql < lines[id(d)])
917
+ out[id(d)] = (vrole, a, b, lines[id(d)], floors[0], floors[1], under,
918
+ tuple(sources), d)
919
+ plot.__dict__["_brackets"] = (calls, key, out, scale)
920
+ return out