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
@@ -0,0 +1,1362 @@
1
+ """The eight geoms: how columns become marks.
2
+
3
+ Each geom implements, once for every plot type that uses it:
4
+
5
+ * ``extent(layer, role, scale)`` and ``values(layer, role)``: the data the axes see, per
6
+ axis of the layer's frame;
7
+ * ``draw(ctx, layer)``: vec_io calls, with the layer's frame mappers (a flipped layer's x
8
+ is the plot's y: orientation is this one swap);
9
+ * ``part(layer)``: its part of the call's legend symbol, as data (``glyph`` draws it);
10
+ * ``occupy(ctx, layer, occ)``: the cells it covers, for legend placement;
11
+ * ``hit(ctx, layer, px, py)``: the index of the item under a page point, or ``None``.
12
+
13
+ Columns are in data units, except those a layer computes once the axes are placed (slot
14
+ widths in px, wedges, labels), which are page units and named so (``page_*``).
15
+ """
16
+
17
+ import array
18
+ import contextlib
19
+ import math
20
+ from typing import Any
21
+
22
+ import vec_io
23
+ from vec_io.arrays import doubles
24
+
25
+ from ..canvas import Canvas
26
+ from .. import kernels
27
+ from ..lines import dash_of
28
+
29
+ __all__ = ["Geom", "Points", "Path", "Band", "Rects", "Polygons", "Segments", "Cells",
30
+ "Text", "GEOMS", "LAYER_OPTIONS", "RECTS", "Marks", "split_band", "with_crossings"]
31
+
32
+ _nan = math.nan
33
+
34
+
35
+ def _is_const(v: Any) -> bool:
36
+ return isinstance(v, (int, float)) and not isinstance(v, bool)
37
+
38
+
39
+ def _many(mapper: Any, v: Any) -> Any:
40
+ """A column (or a constant) through a mapper."""
41
+ if _is_const(v):
42
+ return mapper(float(v))
43
+ return mapper.many(v)
44
+
45
+
46
+ def _ends(mapper: Any, v: Any) -> Any:
47
+ """A column (or a constant) of shape ends through a mapper: values outside the scale's
48
+ domain at the axes' edge (see ``Mapper.ends``)."""
49
+ if _is_const(v):
50
+ return mapper.ends(array.array("d", [float(v)]))[0]
51
+ return mapper.ends(v)
52
+
53
+
54
+ def _map(mapper: Any, v: Any, f_space: bool) -> Any:
55
+ """A column through a mapper; ``f_space``: values already through the scale function
56
+ (contours computed in the axes' scale space)."""
57
+ if f_space:
58
+ return mapper.position(float(v)) if _is_const(v) else kernels.scaled(v, mapper.a,
59
+ mapper.b)
60
+ return _many(mapper, v)
61
+
62
+
63
+ def _columns(v: Any) -> list[Any]:
64
+ """The arrays of a channel: one, several (a list of arrays), or a constant."""
65
+ if v is None:
66
+ return []
67
+ if _is_const(v):
68
+ return [array.array("d", [float(v)])]
69
+ if isinstance(v, list) and v and not _is_const(v[0]) and v[0] is not None:
70
+ return v
71
+ return [v]
72
+
73
+
74
+ class Geom:
75
+ """Base of the geoms: the extent of the channels on each axis of the frame."""
76
+
77
+ #: frame axis → channels whose values are on it
78
+ CHANNELS: dict[str, tuple[str, ...]] = {"x": ("x",), "y": ("y",)}
79
+ #: channels that are ends of shapes (baselines, band edges, segment ends): values
80
+ #: outside the scale's domain are drawn at the axes' edge, not dropped
81
+ ENDS: tuple[str, ...] = ()
82
+ #: the layer options the geom reads (with :data:`LAYER_OPTIONS`, read for every geom)
83
+ OPTIONS: tuple[str, ...] = ()
84
+
85
+ def mapped(self, mapper: Any, v: Any, channel: str) -> Any:
86
+ """A channel's column (or constant) through a mapper, as an end when it is one."""
87
+ return _ends(mapper, v) if channel in self.ENDS else _many(mapper, v)
88
+
89
+ def __repr__(self) -> str:
90
+ return f"{type(self).__name__}()"
91
+
92
+ def extent(self, layer: Any, role: str, scale: Any) -> tuple[int, float, float]:
93
+ """``(count, low, high)`` of the channels on ``role``; on a log scale, shapes with
94
+ an end at or below 0 (drawn from the axes' edge) lower ``low`` to the power of the
95
+ base below it, so that the smallest of them keeps a height."""
96
+ count, lo, hi = 0, math.inf, -math.inf
97
+ edge = False
98
+ for ch in self.CHANNELS.get(role, ()):
99
+ for c in _columns(layer.col(ch)):
100
+ n, a, b = kernels.extent(c, scale.function)
101
+ if n:
102
+ count += n
103
+ lo, hi = min(lo, a), max(hi, b)
104
+ if scale.base is not None and ch in self.ENDS and not edge:
105
+ m, a0, _ = kernels.extent(c, None)
106
+ edge = bool(m) and a0 <= 0
107
+ if not count:
108
+ return 0, math.nan, math.nan
109
+ if edge and lo > 0:
110
+ lo = scale.base ** math.floor(math.log(lo, scale.base) - 1e-9)
111
+ return count, lo, hi
112
+
113
+ def dropped(self, layer: Any, role: str, scale: Any) -> int:
114
+ """Shapes whose every end on ``role`` is outside the scale's domain (not drawn)."""
115
+ chans = self.CHANNELS.get(role, ())
116
+ if len(chans) < 2 or any(ch not in self.ENDS for ch in chans):
117
+ return 0
118
+ cols = [layer.col(ch) for ch in chans]
119
+ if any(c is None for c in cols):
120
+ return 0
121
+ n = max((len(c) for c in cols if not _is_const(c)), default=1)
122
+ outside = [[not scale.contains(float(c)) and math.isfinite(float(c))] * n
123
+ if _is_const(c) else
124
+ [not scale.contains(v) and math.isfinite(v) for v in c] for c in cols]
125
+ return sum(all(o[i] for o in outside) for i in range(n))
126
+
127
+ def values(self, layer: Any, role: str) -> Any:
128
+ out = array.array("d")
129
+ for ch in self.CHANNELS.get(role, ()):
130
+ for c in _columns(layer.col(ch)):
131
+ out.extend(doubles(c))
132
+ return out
133
+
134
+ def draw(self, ctx: Any, layer: Any) -> None:
135
+ raise NotImplementedError
136
+
137
+ def part(self, layer: Any) -> tuple[str, Any] | None:
138
+ """The layer's part of a legend symbol, ``(kind, options)`` (see
139
+ :func:`~plot_io.layers.core.draw_part`); ``None`` by default."""
140
+ return None
141
+
142
+ def occupy(self, ctx: Any, layer: Any, occ: Any) -> None:
143
+ """Nothing by default."""
144
+
145
+ def hit(self, ctx: Any, layer: Any, px: float, py: float) -> int | None:
146
+ return None
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # Points
151
+ # ---------------------------------------------------------------------------
152
+
153
+ class Marks:
154
+ """Marker arguments of a ``Points`` layer that are not a call's marker settings."""
155
+
156
+ def __init__(self, marker: Any = None, size: Any = 6.0, fill: Any = None,
157
+ stroke: Any = None, line_width: Any = None, opacity: Any = 1.0,
158
+ simplify: Any = None) -> None:
159
+ self.marker, self.size, self.fill, self.stroke = marker, size, fill, stroke
160
+ self.line_width, self.opacity, self.simplify = line_width, opacity, simplify
161
+ self.order = None
162
+
163
+ def draw(self, canvas: Canvas, xs: Any, ys: Any, simplify: Any) -> None:
164
+ canvas.draw_markers(xs, ys, marker=self.marker, size=self.size, fill=self.fill,
165
+ stroke=self.stroke, stroke_width=self.line_width,
166
+ opacity=self.opacity, simplify=simplify)
167
+
168
+
169
+ class Points(Geom):
170
+ """Markers at ``(x, y)``: equal-area shapes (scatter, strip, swarm, outliers, line and
171
+ regression markers). Paint: a :class:`~plot_io.layers.paint.MarkerStyle` (or
172
+ :class:`Marks`), called with ``glyph=True`` for the legend symbol. Options:
173
+ ``glyph_at`` (fraction of the symbol box height), ``glyph_max`` (largest marker, ×
174
+ the height), ``radius`` (px marked around each point for legend placement)."""
175
+
176
+ OPTIONS = ("glyph_at", "glyph_max", "radius")
177
+
178
+ def _xy(self, ctx: Any, layer: Any) -> tuple[Any, Any]:
179
+ f = ctx.frame(layer)
180
+ return f.page(_many(f.u, layer.col("x")), _many(f.v, layer.col("y")))
181
+
182
+ def draw(self, ctx: Any, layer: Any) -> None:
183
+ style = layer.style(ctx)
184
+ if not style:
185
+ return
186
+ px, py = self._xy(ctx, layer)
187
+ if isinstance(px, float):
188
+ px, py = [px], [py]
189
+ order = getattr(style, "order", None)
190
+ if order is not None:
191
+ from .paint import permuted
192
+ px, py = permuted(px, order), permuted(py, order)
193
+ if style.fill is None and style.stroke is None:
194
+ return
195
+ style.draw(ctx.canvas, px, py, getattr(style, "simplify", None))
196
+
197
+ def part(self, layer: Any) -> tuple[str, Any] | None:
198
+ o = layer.options
199
+ return "marker", (("at", o.get("glyph_at", 0.5)), ("max", o.get("glyph_max", 1.1)))
200
+
201
+ def occupy(self, ctx: Any, layer: Any, occ: Any) -> None:
202
+ from ..occupancy import thin
203
+ xs, ys = layer.col("x"), layer.col("y")
204
+ if xs is None or ys is None:
205
+ return
206
+ f = ctx.frame(layer)
207
+ a, b = thin(doubles(xs), doubles(ys))
208
+ px, py = f.page(f.u.many(a), f.v.many(b))
209
+ # an unpainted layer stands in for a curve or a density: not markers
210
+ occ.points(px, py, layer.options.get("radius", 0.0), marker=layer.paint is not None)
211
+
212
+ def hit(self, ctx: Any, layer: Any, px: float, py: float) -> int | None:
213
+ xs, ys = self._xy(ctx, layer)
214
+ r = max(layer.options.get("radius", 0.0), 3.0)
215
+ best, best_d = None, r * r
216
+ for i, (a, b) in enumerate(zip(xs, ys)):
217
+ d = (a - px) ** 2 + (b - py) ** 2
218
+ if d <= best_d:
219
+ best, best_d = i, d
220
+ return best
221
+
222
+
223
+ _MARKER_ARGS = ("marker", "size", "fill", "stroke", "line_width", "opacity")
224
+
225
+
226
+ def _per_point(v: Any) -> bool:
227
+ return isinstance(v, (list, array.array)) or (isinstance(v, memoryview) and v.ndim >= 1)
228
+
229
+
230
+ def _rgba_rows(v: Any, n: int) -> Any:
231
+ """A paint as ``n`` flat RGBA rows (``array('d')``), or ``None`` when it is not RGBA."""
232
+ from ..color.values import CMYK, rgba_of
233
+ if isinstance(v, memoryview) and v.ndim == 2:
234
+ m: Any = v
235
+ return array.array("d", m.cast("B").cast("d")) if m.shape[1] == 4 else None
236
+ if isinstance(v, array.array) and len(v) == 4 * n:
237
+ return v
238
+ if isinstance(v, list):
239
+ out = array.array("d")
240
+ for c in v:
241
+ if c is None or isinstance(c, CMYK) or (got := rgba_of(c)) is None:
242
+ return None
243
+ out.extend(got)
244
+ return out
245
+ if isinstance(v, CMYK) or (got := rgba_of(v)) is None:
246
+ return None
247
+ return array.array("d", got) * n
248
+
249
+
250
+ def _joined(name: str, values: list[Any], sizes: list[int]) -> Any:
251
+ """One per-point column of the members' ``values`` (each one value or ``sizes[k]``)."""
252
+ if all(not _per_point(v) for v in values) and all(v == values[0] for v in values):
253
+ return values[0]
254
+ if name == "marker":
255
+ out: list[Any] = []
256
+ for v, k in zip(values, sizes):
257
+ out.extend(v if isinstance(v, list) else [v] * k)
258
+ return out
259
+ if name in ("fill", "stroke"):
260
+ rows = [_rgba_rows(v, k) for v, k in zip(values, sizes)]
261
+ if all(r is not None for r in rows):
262
+ flat = array.array("d")
263
+ for r in rows:
264
+ flat.extend(r)
265
+ return memoryview(flat).cast("B").cast("d", (len(flat) // 4, 4))
266
+ colors: list[Any] = []
267
+ for v, k in zip(values, sizes):
268
+ colors.extend(_colors_list(v, k))
269
+ return colors
270
+ num = array.array("d")
271
+ for v, k in zip(values, sizes):
272
+ num.extend(doubles(v) if _per_point(v) else array.array("d", [float(v)]) * k)
273
+ return num
274
+
275
+
276
+ def _colors_list(v: Any, n: int) -> list[Any]:
277
+ if isinstance(v, list):
278
+ return v
279
+ if isinstance(v, memoryview) and v.ndim == 2:
280
+ rows: Any = v.tolist()
281
+ return [tuple(r) for r in rows]
282
+ if isinstance(v, array.array):
283
+ return [tuple(v[4 * i:4 * i + 4]) for i in range(n)]
284
+ return [v] * n
285
+
286
+
287
+ def _slice(v: Any, a: int, b: int, n: int) -> Any:
288
+ """Items ``a:b`` of a per-point column of ``n``; one value unchanged."""
289
+ if isinstance(v, memoryview) and v.ndim == 2:
290
+ m: Any = v
291
+ k = m.shape[1]
292
+ return m.cast("B").cast("d")[a * k:b * k].cast("B").cast("d", (b - a, k))
293
+ if isinstance(v, array.array) and len(v) == 4 * n and n != len(v):
294
+ return v[4 * a:4 * b]
295
+ if _per_point(v):
296
+ return v[a:b]
297
+ return v
298
+
299
+
300
+ def draw_in_order(ctx: Any, calls: list[Any]) -> bool:
301
+ """Draw the point layers of the members of one call (``group=`` members, level
302
+ members: ``series._rows``) in data order: one ``draw_markers`` when the members differ
303
+ only in what it takes per point, else one per run of consecutive points of a member.
304
+ ``False`` (nothing drawn) when a member is not one ``Points`` layer, or its points do
305
+ not match its rows (a member's ``draw_order``)."""
306
+ members: list[Any] = []
307
+ for call in calls:
308
+ layers = [ly for ly in call.layers if not ly.overlay]
309
+ if len(layers) != 1 or not isinstance(layers[0].geom, Points) \
310
+ or layers[0].options.get("segment") is not None:
311
+ return False
312
+ c = ctx.for_call(call)
313
+ lay = layers[0].columns(c)
314
+ style = lay.style(c)
315
+ rows = call.series.__dict__["_order_rows"][1]
316
+ if not style or style.fill is None and style.stroke is None:
317
+ members.append(None)
318
+ continue
319
+ if getattr(style, "order", None) is not None:
320
+ return False
321
+ px, py = Points()._xy(c, lay)
322
+ if isinstance(px, float):
323
+ px, py = [px], [py]
324
+ if len(px) != len(rows):
325
+ return False
326
+ members.append((style, px, py, rows))
327
+ drawn = [m for m in members if m is not None]
328
+ if not drawn:
329
+ return True
330
+ keys = array.array("d")
331
+ for m in drawn:
332
+ keys.extend(doubles(m[3]))
333
+ order = kernels.argsort(keys)
334
+ sizes = [len(m[1]) for m in drawn]
335
+ styles = [m[0] for m in drawn]
336
+
337
+ def kind(st: Any) -> tuple[Any, ...]:
338
+ return (st.fill is None, st.stroke is None, st.line_width is None,
339
+ getattr(st, "simplify", None))
340
+ canvas = ctx.canvas
341
+ if all(kind(st) == kind(styles[0]) for st in styles):
342
+ from .paint import permuted
343
+ xs = array.array("d")
344
+ ys = array.array("d")
345
+ for m in drawn:
346
+ xs.extend(doubles(m[1]))
347
+ ys.extend(doubles(m[2]))
348
+ args = {a: _joined(a, [getattr(st, a) for st in styles], sizes) for a in _MARKER_ARGS}
349
+ if args["fill"] is None and args["stroke"] is None:
350
+ return True
351
+ args = {a: permuted(v, order) if _per_point(v) else v for a, v in args.items()}
352
+ canvas.draw_markers(permuted(xs, order), permuted(ys, order), marker=args["marker"],
353
+ size=args["size"], fill=args["fill"], stroke=args["stroke"],
354
+ stroke_width=args["line_width"], opacity=args["opacity"],
355
+ simplify=getattr(styles[0], "simplify", None))
356
+ return True
357
+ owner = array.array("q")
358
+ for k, n in enumerate(sizes):
359
+ owner.extend([k] * n)
360
+ start = [0] * len(drawn)
361
+ i, total = 0, len(order)
362
+ while i < total: # runs of one member, in data order
363
+ k = owner[order[i]]
364
+ j = i + 1
365
+ while j < total and owner[order[j]] == k:
366
+ j += 1
367
+ a, b, n = start[k], start[k] + (j - i), sizes[k]
368
+ st, px, py, _ = drawn[k]
369
+ part = Marks(*(_slice(getattr(st, name), a, b, n) for name in _MARKER_ARGS))
370
+ if st.fill is not None or st.stroke is not None:
371
+ part.draw(canvas, _slice(px, a, b, n), _slice(py, a, b, n),
372
+ getattr(st, "simplify", None))
373
+ start[k] = b
374
+ i = j
375
+ return True
376
+
377
+
378
+ # ---------------------------------------------------------------------------
379
+ # Paths and bands
380
+ # ---------------------------------------------------------------------------
381
+
382
+ #: Steps keep their shape when x and y are swapped by swapping the step kind.
383
+ _SWAPPED_STEPS = {"linear": "linear", "step-pre": "step-post", "step-post": "step-pre"}
384
+
385
+
386
+ def _swapped(canvas: Canvas, flip: bool, interpolation: str) -> tuple[bool, str]:
387
+ """Whether a flipped curve is drawn under the x↔y transform (curves along y: steps at
388
+ midpoints, monotone, splines), and the interpolation to pass."""
389
+ if not flip:
390
+ return False, interpolation
391
+ if interpolation in _SWAPPED_STEPS:
392
+ return False, _SWAPPED_STEPS[interpolation]
393
+ return True, interpolation
394
+
395
+
396
+ def _step_points(x: Any, y: Any, interpolation: str) -> tuple[list[float], list[float]]:
397
+ """The vertices of a step line (for legend placement)."""
398
+ xs, ys = list(x), list(y)
399
+ if interpolation not in ("step-pre", "step-post", "step-mid") or len(xs) < 2:
400
+ return xs, ys
401
+ ox, oy = [xs[0]], [ys[0]]
402
+ for i in range(1, len(xs)):
403
+ if interpolation == "step-post":
404
+ ox += [xs[i], xs[i]]
405
+ oy += [ys[i - 1], ys[i]]
406
+ elif interpolation == "step-pre":
407
+ ox += [xs[i - 1], xs[i]]
408
+ oy += [ys[i], ys[i]]
409
+ else:
410
+ m = (xs[i - 1] + xs[i]) / 2
411
+ ox += [m, m, xs[i]]
412
+ oy += [ys[i - 1], ys[i], ys[i]]
413
+ return ox, oy
414
+
415
+
416
+ class Path(Geom):
417
+ """Polylines through ``(x, y)`` with interpolation and missing values (lines, densities,
418
+ ECDFs, regression curves, contour lines, steps). ``x`` and ``y`` are one array each,
419
+ ``y`` several arrays sharing ``x``, or both lists of arrays. Paint: ``color``,
420
+ ``width``, ``dash``, ``opacity``, ``cap``/``join`` (set on the canvas, as line styles
421
+ are), ``interpolation``, ``missing``, ``simplify``. ``page_lines``: polylines in page
422
+ units with their own color and width (gene models placed once the axes are known).
423
+ Options: ``glyph`` (``"line"`` across the symbol box, ``"top"`` along the top of a
424
+ filled swatch), ``ends`` (``y`` values are ends: outlines of fills, see
425
+ :attr:`Geom.ENDS`)."""
426
+
427
+ OPTIONS = ("ends", "f_space", "lists", "glyph", "interpolation")
428
+
429
+ def _v(self, layer: Any, f: Any, b: Any, f_space: bool) -> Any:
430
+ """``y`` values through the frame's v mapper (as ends with the ``ends`` option)."""
431
+ return _ends(f.v, b) if layer.options.get("ends") else _map(f.v, b, f_space)
432
+
433
+ def _paths(self, layer: Any) -> list[tuple[Any, Any]]:
434
+ x, y = layer.col("x"), layer.col("y")
435
+ if isinstance(x, list) and x and not _is_const(x[0]):
436
+ return list(zip(x, y))
437
+ ys = _columns(y)
438
+ return [(x, r) for r in ys]
439
+
440
+ def draw(self, ctx: Any, layer: Any) -> None:
441
+ lines = layer.col("page_lines")
442
+ if lines is not None: # page polylines, each with its paint
443
+ for xs, ys, c, w in lines:
444
+ ctx.canvas.draw_lines(xs, ys, stroke=c, stroke_width=w)
445
+ return
446
+ p = layer.style(ctx)
447
+ c, w = p.get("color"), p.get("width")
448
+ if c is None or not w:
449
+ return
450
+ paths = self._paths(layer)
451
+ if not paths:
452
+ return
453
+ f = ctx.frame(layer)
454
+ canvas = ctx.canvas
455
+ interp = p.get("interpolation", "linear")
456
+ transformed, interp = _swapped(canvas, f.flip, interp)
457
+ fs = layer.options.get("f_space", False)
458
+ us = [_map(f.u, a, fs) for a, _ in paths]
459
+ vs = [self._v(layer, f, b, fs) for _, b in paths]
460
+ scope = contextlib.ExitStack()
461
+ if transformed:
462
+ xs, ys = us, vs
463
+ scope.enter_context(canvas.state())
464
+ canvas.transform(0.0, 1.0, 1.0, 0.0, 0.0, 0.0)
465
+ else:
466
+ xs, ys = (vs, us) if f.flip else (us, vs)
467
+ if p.get("cap") is not None:
468
+ canvas.stroke_linecap = p["cap"]
469
+ if p.get("join") is not None:
470
+ canvas.stroke_linejoin = p["join"]
471
+ kw: dict[str, Any] = {}
472
+ if "opacity" in p:
473
+ kw["opacity"] = p["opacity"]
474
+ if "dash" in p:
475
+ kw["stroke_dasharray"] = p["dash"]
476
+ if "line_cap" in p:
477
+ kw["stroke_linecap"] = p["line_cap"]
478
+ if "simplify" in p:
479
+ kw["simplify"] = p["simplify"]
480
+ if "interpolation" in p:
481
+ kw["interpolation"] = interp
482
+ if "missing" in p:
483
+ kw["missing"] = p["missing"]
484
+ single = len(paths) == 1 and not layer.options.get("lists")
485
+ try:
486
+ canvas.draw_lines(xs[0] if single else xs, ys[0] if single else ys, stroke=c,
487
+ stroke_width=w, **kw)
488
+ finally:
489
+ scope.close()
490
+
491
+ def part(self, layer: Any) -> tuple[str, Any] | None:
492
+ return "line", (("top", layer.options.get("glyph", "line") == "top"),)
493
+
494
+ def occupy(self, ctx: Any, layer: Any, occ: Any) -> None:
495
+ from ..occupancy import extremes
496
+ f = ctx.frame(layer)
497
+ interp = (layer.paint if isinstance(layer.paint, dict) else {}).get(
498
+ "interpolation", layer.options.get("interpolation", "linear"))
499
+ for a, b in self._paths(layer):
500
+ if a is None or b is None:
501
+ continue
502
+ if _is_const(b):
503
+ b = array.array("d", [float(b)] * len(a))
504
+ if interp.startswith("step"):
505
+ a, b = _step_points(a, b, interp)
506
+ a, b = extremes(doubles(a), doubles(b)) # spikes kept
507
+ fs = layer.options.get("f_space", False)
508
+ px, py = f.page(_map(f.u, a, fs), self._v(layer, f, b, fs))
509
+ occ.lines(px, py)
510
+
511
+ def hit(self, ctx: Any, layer: Any, px: float, py: float) -> int | None:
512
+ f = ctx.frame(layer)
513
+ p = layer.style(ctx)
514
+ width = p.get("width")
515
+ tol = (width if width else 1.0) / 2 + 2.0
516
+ for i, (a, b) in enumerate(self._paths(layer)):
517
+ xs, ys = f.page(_many(f.u, a), self._v(layer, f, b, False))
518
+ for k in range(len(xs) - 1):
519
+ if _near_segment(px, py, xs[k], ys[k], xs[k + 1], ys[k + 1], tol):
520
+ return i
521
+ return None
522
+
523
+
524
+ def _near_segment(px: float, py: float, x0: float, y0: float, x1: float, y1: float,
525
+ tol: float) -> bool:
526
+ dx, dy = x1 - x0, y1 - y0
527
+ d2 = dx * dx + dy * dy
528
+ t = 0.0 if not d2 else max(0.0, min(1.0, ((px - x0) * dx + (py - y0) * dy) / d2))
529
+ ex, ey = x0 + t * dx - px, y0 + t * dy - py
530
+ return ex * ex + ey * ey <= tol * tol
531
+
532
+
533
+ def split_band(x: Any, y1: Any, y2: Any,
534
+ crossings: bool) -> tuple[array.array, array.array, array.array, array.array]:
535
+ """``(x, y1, y2, low)`` with ``low`` the smaller of the curves at each point: filling
536
+ between ``y1`` and ``low`` covers where ``y1`` is above, ``y2`` and ``low`` where it is
537
+ below. ``crossings``: points where the curves cross are added (straight segments;
538
+ steps need none). ``y2`` may be a constant."""
539
+ ox, oc = with_crossings(list(x), [list(y1), [y2] * len(x) if _is_const(y2) else list(y2)],
540
+ crossings)
541
+ a, b = oc
542
+ lo = array.array("d", (min(u, v) if math.isfinite(u) and math.isfinite(v) else _nan
543
+ for u, v in zip(a, b)))
544
+ return array.array("d", ox), array.array("d", a), array.array("d", b), lo
545
+
546
+
547
+ def with_crossings(xs: list[float], curves: list[list[float]],
548
+ crossings: bool = True) -> tuple[list[float], list[list[float]]]:
549
+ """Points added where any two straight-segment curves cross (with ``crossings``), so
550
+ that the order of the curves is constant between points: the band splitting of
551
+ ``Band`` (below colors) and of genome signals filled between each other."""
552
+ ox: list[float] = []
553
+ oc: list[list[float]] = [[] for _ in curves]
554
+ for i, x in enumerate(xs):
555
+ if i and crossings:
556
+ a, b = xs[i - 1], x
557
+ ts = set()
558
+ for p in range(len(curves)):
559
+ for q in range(p + 1, len(curves)):
560
+ d0 = curves[p][i - 1] - curves[q][i - 1]
561
+ d1 = curves[p][i] - curves[q][i]
562
+ if d0 * d1 < 0 and math.isfinite(a) and math.isfinite(b):
563
+ ts.add(d0 / (d0 - d1))
564
+ for t in sorted(ts):
565
+ ox.append(a + t * (b - a))
566
+ for c, o in zip(curves, oc):
567
+ o.append(c[i - 1] + t * (c[i] - c[i - 1]))
568
+ ox.append(x)
569
+ for c, o in zip(curves, oc):
570
+ o.append(c[i])
571
+ return ox, oc
572
+
573
+
574
+ #: Band paint keys that may hold one value per band.
575
+ _PER_BAND = ("color", "opacity", "stroke", "below", "below_opacity", "edge")
576
+
577
+
578
+ class Band(Geom):
579
+ """The area between two curves ``y1`` and ``y2`` along ``x`` (``y2`` may be a constant):
580
+ fills, stacked areas, density fills, violin halves, confidence bands, genome signal
581
+ fills. ``x`` and ``y1``/``y2`` may be lists of arrays (several bands: violins).
582
+ Paint: ``color``, ``opacity``, ``below`` (the color where ``y1`` is under ``y2``, the
583
+ curves split where they cross), ``below_opacity``, ``stroke``/``width`` (outline of
584
+ the area), ``edge``/``edge_width`` (both curves as lines), ``interpolation``,
585
+ ``missing``, ``simplify``. Options: ``glyph`` (``"swatch"``: a filled swatch, with
586
+ ``edge`` its outline; ``"band"``: 0.6 of the symbol box's height)."""
587
+
588
+ OPTIONS = ("glyph",)
589
+
590
+ CHANNELS = {"x": ("x",), "y": ("y1", "y2")}
591
+ ENDS = ("y1", "y2")
592
+
593
+ def _bands(self, layer: Any) -> list[tuple[Any, Any, Any]]:
594
+ x, a, b = layer.col("x"), layer.col("y1"), layer.col("y2")
595
+ if isinstance(x, list) and x and not _is_const(x[0]):
596
+ return list(zip(x, a, b))
597
+ return [(x, a, b)]
598
+
599
+ def draw(self, ctx: Any, layer: Any) -> None:
600
+ p = layer.style(ctx)
601
+ f = ctx.frame(layer)
602
+ canvas = ctx.canvas
603
+ interp = p.get("interpolation", "linear")
604
+ bands = self._bands(layer)
605
+ if len(bands) > 1 and interp == "linear" and p.get("below") is None \
606
+ and p.get("edge") is None:
607
+ self._polygons(canvas, f, p, bands)
608
+ return
609
+ transformed = f.flip
610
+ kw: dict[str, Any] = {}
611
+ for k in ("simplify", "missing"):
612
+ if k in p:
613
+ kw[k] = p[k]
614
+ if "interpolation" in p:
615
+ kw["interpolation"] = interp
616
+ with canvas.state() if transformed else contextlib.nullcontext():
617
+ if transformed:
618
+ canvas.transform(0.0, 1.0, 1.0, 0.0, 0.0, 0.0)
619
+ for i, (t, y1, y2) in enumerate(self._bands(layer)):
620
+ pi = {k: (v[i] if isinstance(v, list) and k in _PER_BAND else v)
621
+ for k, v in p.items()}
622
+ self._one(canvas, f, pi, t, y1, y2, interp, kw)
623
+
624
+ def _polygons(self, canvas: Canvas, f: Any, p: dict[str, Any], bands: list[Any]) -> None:
625
+ """Several straight bands in one call (violins), each a polygon: ``y1`` forward,
626
+ ``y2`` back."""
627
+ from ..shapes import draw_polygons
628
+ polys = []
629
+ for t, y1, y2 in bands:
630
+ pu = f.u.many(t)
631
+ ru = array.array("d", pu)
632
+ ru.extend(reversed(pu))
633
+ a, b = self.mapped(f.v, y1, "y1"), self.mapped(f.v, y2, "y2")
634
+ rv = array.array("d", [a] * len(t) if isinstance(a, float) else a)
635
+ low = [b] * len(t) if isinstance(b, float) else b
636
+ rv.extend(reversed(low))
637
+ polys.append([f.page(ru, rv)])
638
+ kw: dict[str, Any] = {}
639
+ if p.get("stroke") is not None:
640
+ kw["stroke"] = p["stroke"]
641
+ kw["stroke_width"] = p.get("width")
642
+ if "simplify" in p:
643
+ kw["simplify"] = p["simplify"]
644
+ draw_polygons(canvas, polys, fill=p.get("color"), **kw)
645
+
646
+ def _one(self, canvas: Canvas, f: Any, p: dict[str, Any], t: Any, y1: Any, y2: Any,
647
+ interp: str, kw: dict[str, Any]) -> None:
648
+ c = p.get("color")
649
+ below = p.get("below")
650
+ opacity = p.get("opacity", 1.0)
651
+ xs = f.u.many(t)
652
+ if c is not None and below is not None:
653
+ a = y1 if not _is_const(y1) else array.array("d", [float(y1)] * len(t))
654
+ st, sa, sb, lo = split_band(t, a, y2, not interp.startswith("step"))
655
+ tx, low = f.u.many(st), f.v.ends(lo)
656
+ canvas.draw_area(tx, f.v.ends(sa), low, fill=c, opacity=opacity, **kw)
657
+ canvas.draw_area(tx, f.v.ends(sb), low, fill=below,
658
+ opacity=p.get("below_opacity", opacity), **kw)
659
+ elif c is not None:
660
+ extra = {}
661
+ if "stroke" in p:
662
+ extra["stroke"] = p["stroke"]
663
+ if p.get("width") is not None:
664
+ extra["stroke_width"] = p["width"]
665
+ canvas.draw_area(xs, _ends(f.v, y1), _ends(f.v, y2), fill=c, opacity=opacity,
666
+ **extra, **kw)
667
+ edge, ew = p.get("edge"), p.get("edge_width")
668
+ if edge is not None and ew:
669
+ ekw = {k: v for k, v in kw.items() if k in ("interpolation", "missing")}
670
+ for y in (y1, y2):
671
+ if _is_const(y):
672
+ if len(xs):
673
+ yv = _ends(f.v, y)
674
+ canvas.draw_lines([min(xs), max(xs)], [yv, yv], stroke=edge, stroke_width=ew)
675
+ else:
676
+ canvas.draw_lines(xs, f.v.ends(y), stroke=edge, stroke_width=ew, **ekw)
677
+
678
+ def part(self, layer: Any) -> tuple[str, Any] | None:
679
+ return "band", (("form", layer.options.get("glyph", "swatch")),)
680
+
681
+ def occupy(self, ctx: Any, layer: Any, occ: Any) -> None:
682
+ from ..occupancy import MAX_VERTICES, thin
683
+ f = ctx.frame(layer)
684
+ for t, a, b in self._bands(layer):
685
+ n = len(t)
686
+ a, b = ([float(v)] * n if _is_const(v) else v for v in (a, b))
687
+ t, a, b = thin(t, a, b, limit=MAX_VERTICES)
688
+ if f.flip:
689
+ occ.band(f.u.many(t), f.v.ends(a), f.v.ends(b), True)
690
+ else:
691
+ occ.band(f.u.many(t), f.v.ends(a), f.v.ends(b))
692
+
693
+ def hit(self, ctx: Any, layer: Any, px: float, py: float) -> int | None:
694
+ f = ctx.frame(layer)
695
+ u, v = (py, px) if f.flip else (px, py)
696
+ for i, (t, a, b) in enumerate(self._bands(layer)):
697
+ pt = f.u.many(t)
698
+ pa, pb = _ends(f.v, a), _ends(f.v, b)
699
+ for k in range(len(pt) - 1):
700
+ lo_t, hi_t = sorted((pt[k], pt[k + 1]))
701
+ if lo_t <= u <= hi_t:
702
+ ya = pa if isinstance(pa, float) else pa[k]
703
+ yb = pb if isinstance(pb, float) else pb[k]
704
+ if min(ya, yb) <= v <= max(ya, yb):
705
+ return i
706
+ return None
707
+
708
+
709
+ # ---------------------------------------------------------------------------
710
+ # Rectangles
711
+ # ---------------------------------------------------------------------------
712
+
713
+ def _touches(rect: Any, width: float, box: Any) -> bool:
714
+ """Whether the outline of ``rect`` (``width``) reaches into ``box``."""
715
+ h = width / 2
716
+ return (box[0] < rect[2] + h and box[2] > rect[0] - h
717
+ and box[1] < rect[3] + h and box[3] > rect[1] - h)
718
+
719
+
720
+ class _Boxes:
721
+ """Seam boxes indexed by their left edge, for the rectangles' outlines they cut."""
722
+
723
+ def __init__(self, boxes: list[Any]) -> None:
724
+ import bisect
725
+ self.boxes = sorted(boxes)
726
+ self.x0 = [b[0] for b in self.boxes]
727
+ self.wide = max((b[2] - b[0] for b in self.boxes), default=0.0)
728
+ self._bisect = bisect
729
+
730
+ def near(self, rect: Any, width: float) -> list[Any]:
731
+ """The boxes the outline of ``rect`` may reach (``_touches`` decides)."""
732
+ h = width / 2
733
+ lo = self._bisect.bisect_left(self.x0, rect[0] - h - self.wide)
734
+ hi = self._bisect.bisect_right(self.x0, rect[2] + h)
735
+ return [b for b in self.boxes[lo:hi] if _touches(rect, width, b)]
736
+
737
+
738
+ class Rects(Geom):
739
+ """Rectangles ``[x0, x1] × [y0, y1]`` (bars, histogram bins, box bodies, interval
740
+ features, genome signal bars). Paint: ``fill`` (one or per item), ``stroke``,
741
+ ``line_width``, ``simplify``; with ``seams`` (bars) ``seam`` (the color where outlined
742
+ rectangles touch) and ``stack``. Options: ``seams`` (outlines of all the plot's seamed
743
+ layers cut where they touch), ``min_width`` (px along x), ``inset`` (outlines inside
744
+ the rectangles), ``each`` (one path per rectangle: box bodies, whose colors keep full
745
+ precision), ``drop_empty`` (rectangles without height left out), ``glyph``
746
+ (``"swatch"``)."""
747
+
748
+ OPTIONS = ("min_width", "drop_empty", "seams", "each", "inset")
749
+
750
+ CHANNELS = {"x": ("x0", "x1"), "y": ("y0", "y1")}
751
+ ENDS = ("x0", "x1", "y0", "y1")
752
+
753
+ def page_rects(self, ctx: Any, layer: Any) -> list[tuple[float, float, float, float, bool]]:
754
+ """Page rectangles ``(x0, y0, x1, y1, negative)``: ``negative`` when ``y1 < y0``."""
755
+ f = ctx.frame(layer)
756
+ x0, x1, y0, y1 = (layer.col(c) for c in ("x0", "x1", "y0", "y1"))
757
+ if x0 is None or not len(x0):
758
+ return []
759
+ a0, a1 = self.mapped(f.u, x0, "x0"), self.mapped(f.u, x1, "x1")
760
+ v0, v1 = self.mapped(f.v, y0, "y0"), self.mapped(f.v, y1, "y1")
761
+ n = len(x0)
762
+
763
+ def at(c: Any, i: int) -> float:
764
+ return c if isinstance(c, float) else c[i]
765
+
766
+ mw = layer.options.get("min_width", 0.0)
767
+ ys0 = y0 if not _is_const(y0) else None
768
+ ys1 = y1 if not _is_const(y1) else None
769
+ out = []
770
+ for i in range(n):
771
+ pa, pb = at(a0, i), at(a1, i)
772
+ if mw:
773
+ pa, pb = min(pa, pb), max(pa, pb)
774
+ if pb - pa < mw:
775
+ c = (pa + pb) / 2
776
+ pa, pb = c - mw / 2, c + mw / 2
777
+ qa, qb = at(v0, i), at(v1, i)
778
+ neg = (ys1[i] if ys1 is not None else y1) < (ys0[i] if ys0 is not None else y0)
779
+ if f.flip:
780
+ out.append((min(qa, qb), min(pa, pb), max(qa, qb), max(pa, pb), neg))
781
+ else:
782
+ out.append((min(pa, pb), min(qa, qb), max(pa, pb), max(qa, qb), neg))
783
+ return out
784
+
785
+ def kept(self, layer: Any, rects: list[Any]) -> list[int]:
786
+ """The rectangles drawn: finite ones, and with ``drop_empty`` those of some height
787
+ (histogram bins without values are left out)."""
788
+ keep = [i for i, r in enumerate(rects) if all(math.isfinite(v) for v in r[:4])]
789
+ if layer.options.get("drop_empty"):
790
+ y0, y1 = layer.col("y0"), layer.col("y1")
791
+ keep = [i for i in keep if y0[i] != y1[i]]
792
+ return keep
793
+
794
+ def draw(self, ctx: Any, layer: Any) -> None:
795
+ p = layer.style(ctx)
796
+ if layer.options.get("seams"):
797
+ _, _, pages = self._seam_plan(ctx)
798
+ rects = pages.get(layer.table.origin)
799
+ self._draw_seamed(ctx, layer, p, rects if rects is not None
800
+ else self.page_rects(ctx, layer))
801
+ return
802
+ rects = self.page_rects(ctx, layer)
803
+ keep = self.kept(layer, rects)
804
+ fill, stroke = p.get("fill"), p.get("stroke")
805
+ lw = p.get("line_width")
806
+ if not keep or (fill is None and stroke is None and "stroke" in p):
807
+ return
808
+ xs = array.array("d", (rects[i][0] for i in keep))
809
+ ys = array.array("d", (rects[i][1] for i in keep))
810
+ ws = array.array("d", (rects[i][2] - rects[i][0] for i in keep))
811
+ hs = array.array("d", (rects[i][3] - rects[i][1] for i in keep))
812
+ if len(keep) != len(rects):
813
+ fill = [fill[i] for i in keep] if isinstance(fill, list) else fill
814
+ stroke = [stroke[i] for i in keep] if isinstance(stroke, list) else stroke
815
+ if layer.options.get("each"):
816
+ # one path per rectangle, filled then outlined (colors at full precision:
817
+ # vec_io's draw_rects writes 4 decimals)
818
+ canvas = ctx.canvas
819
+ for k in range(len(xs)):
820
+ f = fill[k] if isinstance(fill, list) else fill
821
+ c = stroke[k] if isinstance(stroke, list) else stroke
822
+ path = vec_io.Path().rect(xs[k], ys[k], ws[k], hs[k])
823
+ if f is not None:
824
+ canvas.draw_path(path, fill=f, stroke=None)
825
+ if c is not None and lw:
826
+ canvas.stroke_width = lw
827
+ canvas.draw_path(path, fill=None, stroke=c)
828
+ return
829
+ if layer.options.get("inset") and stroke is not None:
830
+ # the outline inside the rectangle: one narrower than two borders is one line
831
+ # of `line_width`, its left and right borders overlapping
832
+ h = lw / 2
833
+ xs = array.array("d", [x + min(h, w / 2) for x, w in zip(xs, ws)])
834
+ ys = array.array("d", [y + min(h, v / 2) for y, v in zip(ys, hs)])
835
+ ws = array.array("d", [max(0.0, w - lw) for w in ws])
836
+ hs = array.array("d", [max(0.0, v - lw) for v in hs])
837
+ kw: dict[str, Any] = {}
838
+ for name in ("simplify", "opacity"):
839
+ if name in p:
840
+ kw[name] = p[name]
841
+ if "stroke" in p: # else the canvas' stroke style
842
+ kw["stroke"] = stroke
843
+ kw["stroke_width"] = lw
844
+ ctx.canvas.draw_rects(xs, ys, ws, hs, fill=fill, **kw)
845
+
846
+ # -- seams (bars) ------------------------------------------------------------------
847
+
848
+ @staticmethod
849
+ def _seam_plan(ctx: Any) -> tuple[dict[int, tuple[list[Any], list[Any]]], Any,
850
+ dict[int, list[Any]]]:
851
+ """The seams and the lines between stacked parts ``(segment, color)`` each seamed
852
+ layer of the plot draws (by ``id`` of its table), and the boxes all of them cover,
853
+ which outlines leave out. Rectangles whose outlines have different widths get no
854
+ seam. Layers in drawing order; computed once per context."""
855
+ from ..seams import rect_contacts, seam_box
856
+ root = ctx.root
857
+ cache = root.__dict__.get("_seams")
858
+ if cache is not None:
859
+ return cache
860
+ from .pipeline import drawn_layers
861
+ lines: dict[int, tuple[list[Any], list[Any]]] = {}
862
+ known: dict[int, set[Any]] = {} # the seams of each table, as a set
863
+ pages: dict[int, list[Any]] = {} # page rectangles of each table
864
+ boxes: list[Any] = []
865
+ drawn: list[Any] = [] # outlined rectangles so far
866
+ colors: list[Any] = [] # their outline colors
867
+ for call, layer in drawn_layers(ctx.plot):
868
+ if not layer.options.get("seams"):
869
+ continue
870
+ c = root.for_call(call)
871
+ lay = layer.columns(c)
872
+ p = lay.style(c)
873
+ lc, lw = p.get("stroke"), p.get("line_width")
874
+ if lc is None or not lw:
875
+ continue
876
+ rects = RECTS.page_rects(c, lay)
877
+ pages[layer.table.origin] = rects
878
+ keep = [i for i, r in enumerate(rects) if all(math.isfinite(v) for v in r[:4])]
879
+ mine = [(*rects[i][:4], lw) for i in keep]
880
+ seam = p.get("seam")
881
+ stack = bool(p.get("stack"))
882
+ seams, ends = lines.setdefault(layer.table.origin, ([], []))
883
+ seen = known.setdefault(layer.table.origin, set())
884
+ for seg, j in rect_contacts(mine, drawn):
885
+ if (drawn[j] if j < len(drawn) else mine[j - len(drawn)])[4] != lw:
886
+ continue # outlines of another width: both stay whole
887
+ if stack and j < len(drawn) and (seg[0] == seg[2]) == layer.flip:
888
+ # on an earlier part of the stack: the line between keeps its color
889
+ if colors[j] is not None:
890
+ ends.append((seg, colors[j]))
891
+ boxes.append(seam_box(seg, square=True))
892
+ elif seg not in seen:
893
+ seen.add(seg)
894
+ seams.append(seg)
895
+ if seam is not None:
896
+ boxes.append(seam_box(seg))
897
+ drawn.extend(mine)
898
+ colors.extend([lc[i] for i in keep] if isinstance(lc, list) else [lc] * len(keep))
899
+ out = (lines, _Boxes(boxes), pages)
900
+ root.__dict__["_seams"] = out
901
+ return out
902
+
903
+ def _draw_seamed(self, ctx: Any, layer: Any, p: dict[str, Any], rects: list[Any]) -> None:
904
+ from ..seams import draw_seams, outline_pieces
905
+ from ..shapes import draw_rects, draw_polygons
906
+ fills: Any = p.get("fill")
907
+ lc: Any = p.get("stroke")
908
+ width = p.get("line_width")
909
+ lw: float = width if width else 0.0
910
+ simplify = p.get("simplify")
911
+ keep = [i for i, r in enumerate(rects) if all(math.isfinite(v) for v in r[:4])]
912
+ lines, boxes, _ = self._seam_plan(ctx)
913
+ seams, ends = lines.get(layer.table.origin, ([], []))
914
+ canvas = ctx.canvas
915
+ stroke = [lc[i] for i in keep] if isinstance(lc, list) else [lc] * len(keep)
916
+ rs = [rects[i][:4] for i in keep]
917
+ near = [boxes.near(r, lw) if lc is not None and lw else [] for r in rs]
918
+ cut = [k for k in range(len(rs)) if near[k]]
919
+ cut_set = set(cut)
920
+ whole = [k for k in range(len(rs)) if k not in cut_set]
921
+ draw_rects(canvas, [rs[k][0] for k in whole], [rs[k][1] for k in whole],
922
+ [rs[k][2] - rs[k][0] for k in whole], [rs[k][3] - rs[k][1] for k in whole],
923
+ fill=[fills[keep[k]] for k in whole],
924
+ stroke=[stroke[k] for k in whole] if lc is not None else None,
925
+ stroke_width=lw or None, simplify=simplify)
926
+ if cut:
927
+ # outlines under seams are left out of the geometry, one path per rectangle
928
+ draw_rects(canvas, [rs[k][0] for k in cut], [rs[k][1] for k in cut],
929
+ [rs[k][2] - rs[k][0] for k in cut], [rs[k][3] - rs[k][1] for k in cut],
930
+ fill=[fills[keep[k]] for k in cut], simplify=simplify)
931
+ polys, colors = [], []
932
+ for k in cut:
933
+ pieces = outline_pieces(rs[k], lw, near[k])
934
+ if pieces and stroke[k] is not None:
935
+ polys.append([([q[0], q[2], q[2], q[0]], [q[1], q[1], q[3], q[3]])
936
+ for q in pieces])
937
+ colors.append(stroke[k])
938
+ draw_polygons(canvas, polys, fill=colors, simplify=simplify)
939
+ draw_seams(canvas, seams, p.get("seam"))
940
+ if ends:
941
+ canvas.draw_segments(*([s[k] for s, _ in ends] for k in range(4)),
942
+ stroke=[c for _, c in ends],
943
+ stroke_width=[s[4] for s, _ in ends], stroke_linecap="square")
944
+
945
+ def part(self, layer: Any) -> tuple[str, Any] | None:
946
+ return "rect", ()
947
+
948
+ def occupy(self, ctx: Any, layer: Any, occ: Any) -> None:
949
+ rects = [r for r in self.page_rects(ctx, layer) if all(math.isfinite(v) for v in r[:4])]
950
+ if rects:
951
+ occ.rects([r[0] for r in rects], [r[1] for r in rects], [r[2] for r in rects],
952
+ [r[3] for r in rects])
953
+
954
+ def hit(self, ctx: Any, layer: Any, px: float, py: float) -> int | None:
955
+ for i, (x0, y0, x1, y1, _) in enumerate(self.page_rects(ctx, layer)):
956
+ if x0 <= px <= x1 and y0 <= py <= y1:
957
+ return i
958
+ return None
959
+
960
+
961
+ # ---------------------------------------------------------------------------
962
+ # Polygons
963
+ # ---------------------------------------------------------------------------
964
+
965
+ class Polygons(Geom):
966
+ """Filled polygons: ``rings`` (per polygon, a list of rings ``(xs, ys)`` in data
967
+ units, filled together), ``ring_sets`` (per polygon, vec_io ring arrays ``(xs, ys,
968
+ ring_starts)`` after the axis scale functions), ``page_paths`` (vec_io ``Path``
969
+ objects in page units: wedges), or one ``shape`` repeated at ``x``/``y`` page centres
970
+ (hexagons, drawn as markers). Paint: ``fill``, ``stroke``, ``line_width``,
971
+ ``opacity``, ``simplify``; with ``outlines`` (paths stroked instead of the polygons'
972
+ outlines) and ``seams`` (segments in ``seam`` color). Options: ``glyph``
973
+ (``"swatch"``, ``"hexagon"``)."""
974
+
975
+ OPTIONS = ("glyph",)
976
+
977
+ CHANNELS = {"x": ("x",), "y": ("y",)}
978
+
979
+ def extent(self, layer: Any, role: str, scale: Any) -> tuple[int, float, float]:
980
+ rings = layer.col("rings")
981
+ if rings is None:
982
+ return 0, math.nan, math.nan
983
+ count, lo, hi = 0, math.inf, -math.inf
984
+ k = 0 if role == "x" else 1
985
+ for poly in rings:
986
+ for ring in poly:
987
+ n, a, b = kernels.extent(ring[k], scale.function)
988
+ if n:
989
+ count += n
990
+ lo, hi = min(lo, a), max(hi, b)
991
+ return (count, lo, hi) if count else (0, math.nan, math.nan)
992
+
993
+ def values(self, layer: Any, role: str) -> Any:
994
+ return None
995
+
996
+ def draw(self, ctx: Any, layer: Any) -> None:
997
+ p = layer.style(ctx)
998
+ canvas = ctx.canvas
999
+ kw: dict[str, Any] = {}
1000
+ if "simplify" in p:
1001
+ kw["simplify"] = p["simplify"]
1002
+ shape = layer.col("shape")
1003
+ if shape is not None:
1004
+ xs, ys = layer.col("x"), layer.col("y")
1005
+ if not len(xs):
1006
+ return
1007
+ canvas.draw_markers(xs, ys, marker=shape, size=layer.col("size"),
1008
+ fill=p.get("fill"), stroke=p.get("stroke"),
1009
+ stroke_width=p.get("line_width"), opacity=p.get("opacity", 1.0),
1010
+ **kw)
1011
+ return
1012
+ paths = layer.col("page_paths")
1013
+ if paths is not None:
1014
+ self._paths(canvas, p, paths, kw)
1015
+ return
1016
+ sets = layer.col("ring_sets")
1017
+ if sets is not None:
1018
+ from ..shapes import fill_ring_sets
1019
+ f = ctx.frame(layer)
1020
+ xm, ym = f.u, f.v
1021
+ mapped = [(kernels.scaled(xs, xm.a, xm.b), kernels.scaled(ys, ym.a, ym.b), st)
1022
+ for xs, ys, st in sets]
1023
+ fill_ring_sets(canvas, mapped, p.get("fill"), **kw)
1024
+ return
1025
+ rings = layer.col("rings")
1026
+ if not rings:
1027
+ return
1028
+ from ..shapes import draw_polygons
1029
+ f = ctx.frame(layer)
1030
+ polys = [[f.page(f.u.many(rx), f.v.many(ry)) for rx, ry in poly] for poly in rings]
1031
+ if p.get("stroke") is not None:
1032
+ kw["stroke"] = p["stroke"]
1033
+ kw["stroke_width"] = p.get("line_width")
1034
+ draw_polygons(canvas, polys, fill=p.get("fill"), **kw)
1035
+
1036
+ @staticmethod
1037
+ def _paths(canvas: Canvas, p: dict[str, Any], paths: list[Any], kw: dict[str, Any]) -> None:
1038
+ from ..seams import draw_seams
1039
+ fills: Any = p.get("fill")
1040
+ strokes: Any = p.get("stroke")
1041
+ lw: Any = p.get("line_width")
1042
+ outlines = p.get("outlines")
1043
+ if outlines is None:
1044
+ if paths:
1045
+ canvas.draw_polygons(paths, fill=fills, stroke=strokes if lw else None,
1046
+ stroke_width=lw or None, **kw)
1047
+ return
1048
+ # outlines under seams are left out of the geometry (not painted over), so none
1049
+ # shows through a seam's anti-aliased edges
1050
+ canvas.draw_polygons(paths, fill=fills, stroke=None, **kw)
1051
+ with canvas.state():
1052
+ canvas.stroke_width = lw
1053
+ for path, stroke in zip(outlines, strokes):
1054
+ if stroke is not None:
1055
+ canvas.draw_path(path, fill=None, stroke=stroke)
1056
+ draw_seams(canvas, p.get("seams") or [], p.get("seam"))
1057
+
1058
+ def part(self, layer: Any) -> tuple[str, Any] | None:
1059
+ return "polygon", (("form", layer.options.get("glyph")),)
1060
+
1061
+ def occupy(self, ctx: Any, layer: Any, occ: Any) -> None:
1062
+ if layer.col("shape") is not None:
1063
+ hw, hh = layer.col("half")
1064
+ xs, ys = layer.col("x"), layer.col("y")
1065
+ occ.rects([x - hw for x in xs], [y - hh for y in ys], [x + hw for x in xs],
1066
+ [y + hh for y in ys])
1067
+ return
1068
+ rings = layer.col("rings")
1069
+ if not rings:
1070
+ return
1071
+ f = ctx.frame(layer)
1072
+ for poly in rings:
1073
+ for rx, ry in poly:
1074
+ px, py = f.page(f.u.many(rx), f.v.many(ry))
1075
+ occ.rects([min(px)], [min(py)], [max(px)], [max(py)])
1076
+
1077
+ def hit(self, ctx: Any, layer: Any, px: float, py: float) -> int | None:
1078
+ rings = layer.col("rings")
1079
+ if not rings:
1080
+ return None
1081
+ f = ctx.frame(layer)
1082
+ for i, poly in enumerate(rings):
1083
+ inside = False
1084
+ for rx, ry in poly:
1085
+ xs, ys = f.page(f.u.many(rx), f.v.many(ry))
1086
+ n = len(xs)
1087
+ for k in range(n):
1088
+ x0, y0, x1, y1 = xs[k], ys[k], xs[(k + 1) % n], ys[(k + 1) % n]
1089
+ if (y0 > py) != (y1 > py) and px < x0 + (py - y0) * (x1 - x0) / (y1 - y0):
1090
+ inside = not inside
1091
+ if inside:
1092
+ return i
1093
+ return None
1094
+
1095
+
1096
+ # ---------------------------------------------------------------------------
1097
+ # Segments
1098
+ # ---------------------------------------------------------------------------
1099
+
1100
+ class Segments(Geom):
1101
+ """Segments ``(x0, y0)–(x1, y1)`` (error bars, stems, whiskers, caps, medians), or
1102
+ ticks: segments ``tick`` px long across each point ``(x0, y0)`` (caps of error bars,
1103
+ rugs). Paint: ``color`` and ``width`` (one or per item), ``opacity``, ``cap``,
1104
+ ``simplify``, ``dash`` (per item: dashed items are drawn as lines, by color, after the
1105
+ solid ones). Options: ``tick`` (``"u"`` or ``"v"``: the frame axis the ticks run
1106
+ along), ``glyph`` (``"errorbar"``, ``"stem"``)."""
1107
+
1108
+ OPTIONS = ("tick", "glyph")
1109
+
1110
+ CHANNELS = {"x": ("x0", "x1"), "y": ("y0", "y1")}
1111
+ ENDS = ("x0", "x1", "y0", "y1") # ticks are points: theirs are not ends
1112
+
1113
+ def _ends(self, f: Any, layer: Any) -> tuple[Any, Any, Any, Any]:
1114
+ """Page coordinates of the segments' ends."""
1115
+ a0, b0 = f.page(self.mapped(f.u, layer.col("x0"), "x0"),
1116
+ self.mapped(f.v, layer.col("y0"), "y0"))
1117
+ a1, b1 = f.page(self.mapped(f.u, layer.col("x1"), "x1"),
1118
+ self.mapped(f.v, layer.col("y1"), "y1"))
1119
+ return a0, b0, a1, b1
1120
+
1121
+ def draw(self, ctx: Any, layer: Any) -> None:
1122
+ p = layer.style(ctx)
1123
+ if not p or p.get("color") is None:
1124
+ return
1125
+ f = ctx.frame(layer)
1126
+ canvas = ctx.canvas
1127
+ x0 = layer.col("x0")
1128
+ if x0 is None or not len(x0):
1129
+ return
1130
+ tick = layer.options.get("tick")
1131
+ kw: dict[str, Any] = {}
1132
+ for k, name in (("opacity", "opacity"), ("simplify", "simplify"),
1133
+ ("cap", "stroke_linecap")):
1134
+ if k in p:
1135
+ kw[name] = p[k]
1136
+ if tick is not None:
1137
+ pu, pv = _many(f.u, x0), _many(f.v, layer.col("y0"))
1138
+ h = p["tick"] / 2
1139
+ if tick == "u":
1140
+ a0, b0 = f.page(kernels.scaled(pu, 1.0, -h), pv)
1141
+ a1, b1 = f.page(kernels.scaled(pu, 1.0, h), pv)
1142
+ else:
1143
+ a0, b0 = f.page(pu, kernels.scaled(pv, 1.0, -h))
1144
+ a1, b1 = f.page(pu, kernels.scaled(pv, 1.0, h))
1145
+ canvas.draw_segments(a0, b0, a1, b1, stroke=p.get("color"), stroke_width=p.get("width"),
1146
+ **kw)
1147
+ return
1148
+ a0, b0, a1, b1 = self._ends(f, layer)
1149
+ dash = p.get("dash")
1150
+ color, width = p.get("color"), p.get("width")
1151
+ if not isinstance(dash, list) and not isinstance(color, list) \
1152
+ and not isinstance(width, list):
1153
+ canvas.draw_segments(a0, b0, a1, b1, stroke=color, stroke_width=width, **kw)
1154
+ return
1155
+ n = len(x0)
1156
+
1157
+ def at(v: Any, i: int) -> Any:
1158
+ return v[i] if isinstance(v, list) else v
1159
+
1160
+ def item(v: Any, i: int) -> float:
1161
+ return v if isinstance(v, float) else v[i]
1162
+
1163
+ solid = [i for i in range(n) if at(dash, i) is None and at(color, i) is not None
1164
+ and at(width, i)]
1165
+ if solid:
1166
+ canvas.draw_segments([item(a0, i) for i in solid], [item(b0, i) for i in solid],
1167
+ [item(a1, i) for i in solid], [item(b1, i) for i in solid],
1168
+ stroke=[at(color, i) for i in solid],
1169
+ stroke_width=[at(width, i) for i in solid], **kw)
1170
+ dashed: dict[Any, tuple[list[float], list[float], Any, Any]] = {}
1171
+ for i in range(n):
1172
+ d, c, w = at(dash, i), at(color, i), at(width, i)
1173
+ if d is None or c is None or not w:
1174
+ continue
1175
+ xs_, ys_, _, _ = dashed.setdefault(c, ([], [], w, d))
1176
+ xs_ += [item(a0, i), item(a1, i), _nan]
1177
+ ys_ += [item(b0, i), item(b1, i), _nan]
1178
+ for c, (xs_, ys_, w, d) in dashed.items():
1179
+ canvas.draw_lines(xs_, ys_, stroke=c, stroke_width=w, stroke_dasharray=dash_of(d, w), stroke_linecap="butt")
1180
+
1181
+ def part(self, layer: Any) -> tuple[str, Any] | None:
1182
+ return "rule", (("form", layer.options.get("glyph")),)
1183
+
1184
+ def occupy(self, ctx: Any, layer: Any, occ: Any) -> None:
1185
+ x0 = layer.col("x0")
1186
+ if x0 is None or not len(x0) or layer.options.get("tick") is not None:
1187
+ return
1188
+ f = ctx.frame(layer)
1189
+ a0, b0, a1, b1 = self._ends(f, layer)
1190
+ xs, ys = array.array("d"), array.array("d")
1191
+ for i in range(len(x0)):
1192
+ xs.extend((a0 if isinstance(a0, float) else a0[i], a1 if isinstance(a1, float)
1193
+ else a1[i], _nan))
1194
+ ys.extend((b0 if isinstance(b0, float) else b0[i], b1 if isinstance(b1, float)
1195
+ else b1[i], _nan))
1196
+ occ.lines(xs, ys)
1197
+
1198
+ def hit(self, ctx: Any, layer: Any, px: float, py: float) -> int | None:
1199
+ x0 = layer.col("x0")
1200
+ if x0 is None or layer.options.get("tick") is not None:
1201
+ return None
1202
+ f = ctx.frame(layer)
1203
+ a0, b0, a1, b1 = self._ends(f, layer)
1204
+
1205
+ def item(v: Any, i: int) -> float:
1206
+ return v if isinstance(v, float) else v[i]
1207
+
1208
+ for i in range(len(x0)):
1209
+ if _near_segment(px, py, item(a0, i), item(b0, i), item(a1, i), item(b1, i), 3.0):
1210
+ return i
1211
+ return None
1212
+
1213
+
1214
+ # ---------------------------------------------------------------------------
1215
+ # Cells
1216
+ # ---------------------------------------------------------------------------
1217
+
1218
+ class Cells(Geom):
1219
+ """A grid of cells colored by value or given as colors (heatmaps, 2-D histograms,
1220
+ Hi-C). Paint: the arguments of vec_io's ``draw_cells`` (``x_edges``/``y_edges`` in
1221
+ page units, ``fill``, ``palette``, ``bounds``, ``bad``, ``mode``, ``opacity``), with
1222
+ ``even`` (edges evenly spaced within 1e-6 of a cell: drawn as exactly even cells) or a
1223
+ ``transform`` placing the grid ``(0, 0)–(columns, rows)`` (Hi-C's rotated triangle).
1224
+ The extent is the call's (cell edges through the scale)."""
1225
+
1226
+ CHANNELS: dict[str, tuple[str, ...]] = {}
1227
+
1228
+ def draw(self, ctx: Any, layer: Any) -> None:
1229
+ p = layer.style(ctx)
1230
+ if p is None:
1231
+ return
1232
+ canvas = ctx.canvas
1233
+ transform = p.pop("transform", None)
1234
+ if transform is not None:
1235
+ with canvas.state():
1236
+ canvas.transform(*transform)
1237
+ canvas.draw_cells(**p)
1238
+ return
1239
+ if p.pop("even"):
1240
+ p["x_edges"], p["y_edges"] = _even_edges(p["x_edges"]), _even_edges(p["y_edges"])
1241
+ elif p.get("mode") == "image":
1242
+ p["mode"] = "cells"
1243
+ canvas.draw_cells(**p)
1244
+
1245
+ def occupy(self, ctx: Any, layer: Any, occ: Any) -> None:
1246
+ x0, y0, x1, y1 = ctx.rect
1247
+ occ.rects([x0], [y0], [x1], [y1])
1248
+
1249
+ def hit(self, ctx: Any, layer: Any, px: float, py: float) -> int | None:
1250
+ p = layer.style(ctx)
1251
+ if p is None or p.get("transform") is not None:
1252
+ return None
1253
+ xe, ye = p["x_edges"], p["y_edges"]
1254
+ cols = len(xe) - 1
1255
+ for r in range(len(ye) - 1):
1256
+ if ye[r] <= py <= ye[r + 1]:
1257
+ for c in range(cols):
1258
+ if xe[c] <= px <= xe[c + 1]:
1259
+ return r * cols + c
1260
+ return None
1261
+
1262
+
1263
+ def _even_edges(e: Any) -> list[float]:
1264
+ """Edges evenly spaced from ``e[0]`` to ``e[-1]`` (both kept exactly)."""
1265
+ n = len(e) - 1
1266
+ a, b = e[0], e[-1]
1267
+ return [a + (b - a) * j / n for j in range(n)] + [b]
1268
+
1269
+
1270
+ # ---------------------------------------------------------------------------
1271
+ # Text
1272
+ # ---------------------------------------------------------------------------
1273
+
1274
+ class Text(Geom):
1275
+ """Labels at points: ``page_x``/``page_y`` (page units, placed once the axes are known:
1276
+ value labels, cell labels, contour and wedge labels, gene names) or ``x``/``y`` (data).
1277
+ Columns ``text`` and optionally ``color`` (per label), ``ha``, ``va``, ``angle``;
1278
+ ``boxes`` (a background rectangle ``(w, h)`` behind each label, rotated with it) and
1279
+ ``leaders`` (a line ``(x0, y0, x1, y1, …)`` through its points, drawn before each
1280
+ label, or ``None``). Paint: ``font``, ``background``, ``ha``/``va``/``angle`` for all
1281
+ labels."""
1282
+
1283
+ CHANNELS: dict[str, tuple[str, ...]] = {}
1284
+
1285
+ def _positions(self, ctx: Any, layer: Any) -> tuple[Any, Any]:
1286
+ px = layer.col("page_x")
1287
+ if px is not None:
1288
+ return px, layer.col("page_y")
1289
+ f = ctx.frame(layer)
1290
+ return f.page(_many(f.u, layer.col("x")), _many(f.v, layer.col("y")))
1291
+
1292
+ def draw(self, ctx: Any, layer: Any) -> None:
1293
+ from ..text import draw_text
1294
+ p = layer.style(ctx)
1295
+ texts = layer.col("text")
1296
+ if not texts:
1297
+ return
1298
+ canvas = ctx.canvas
1299
+ xs, ys = self._positions(ctx, layer)
1300
+ font = p["font"]
1301
+ colors, has, vas = layer.col("color"), layer.col("ha"), layer.col("va")
1302
+ angles, boxes, leaders = layer.col("angle"), layer.col("boxes"), layer.col("leaders")
1303
+ for i, text in enumerate(texts):
1304
+ if text is None or not (math.isfinite(xs[i]) and math.isfinite(ys[i])):
1305
+ continue
1306
+ ang = (angles[i] if angles is not None else None) or p.get("angle", 0.0)
1307
+ if leaders is not None and leaders[i] is not None and all(
1308
+ math.isfinite(v) for v in leaders[i]):
1309
+ pts = leaders[i]
1310
+ canvas.draw_lines(list(pts[0::2]), list(pts[1::2]), stroke=font.color,
1311
+ stroke_width=0.5)
1312
+ if boxes is not None and boxes[i] is not None:
1313
+ bw, bh, pad = boxes[i]
1314
+ with canvas.state():
1315
+ canvas.translate(xs[i], ys[i])
1316
+ canvas.rotate(-ang)
1317
+ canvas.fill = p["background"]
1318
+ canvas.draw_rect(-bw / 2 - pad, -bh / 2, bw + 2 * pad, bh, stroke=None)
1319
+ f = font.with_color(colors[i]) if colors is not None else font
1320
+ ha = has[i] if has is not None else p.get("ha", "center")
1321
+ va = vas[i] if vas is not None else p.get("va", "center")
1322
+ if ang:
1323
+ draw_text(canvas, text, xs[i], ys[i], f, ha, va, ang)
1324
+ else:
1325
+ draw_text(canvas, text, xs[i], ys[i], f, ha, va)
1326
+
1327
+ def _boxes(self, ctx: Any, layer: Any) -> list[tuple[float, float, float, float]]:
1328
+ from ..text import measure
1329
+ p = layer.style(ctx)
1330
+ texts = layer.col("text") or []
1331
+ xs, ys = self._positions(ctx, layer)
1332
+ out = []
1333
+ for i, text in enumerate(texts):
1334
+ if text is None or not (math.isfinite(xs[i]) and math.isfinite(ys[i])):
1335
+ continue
1336
+ w, a, d = measure(ctx.canvas, text, p["font"])
1337
+ out.append((xs[i] - w / 2, ys[i] - (a + d) / 2, xs[i] + w / 2, ys[i] + (a + d) / 2))
1338
+ return out
1339
+
1340
+ def occupy(self, ctx: Any, layer: Any, occ: Any) -> None:
1341
+ boxes = self._boxes(ctx, layer)
1342
+ if boxes:
1343
+ occ.rects(*([b[k] for b in boxes] for k in range(4)))
1344
+
1345
+ def hit(self, ctx: Any, layer: Any, px: float, py: float) -> int | None:
1346
+ for i, (x0, y0, x1, y1) in enumerate(self._boxes(ctx, layer)):
1347
+ if x0 <= px <= x1 and y0 <= py <= y1:
1348
+ return i
1349
+ return None
1350
+
1351
+
1352
+ #: The rectangles geom (its page rectangles place value labels).
1353
+ RECTS = Rects()
1354
+ #: One instance of each geom (geoms hold no state).
1355
+ #: Layer options read for every geom: ``bounds`` (``False``: not in the axes' limits),
1356
+ #: ``segment`` (the genome segment it is drawn in), ``occupy_aes`` (the channels the legend
1357
+ #: placement reads).
1358
+ LAYER_OPTIONS = ("bounds", "segment", "occupy_aes")
1359
+
1360
+ GEOMS: dict[str, Geom] = {"points": Points(), "path": Path(), "band": Band(), "rects": RECTS,
1361
+ "polygons": Polygons(), "segments": Segments(), "cells": Cells(),
1362
+ "text": Text()}