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/labels.py ADDED
@@ -0,0 +1,566 @@
1
+ """The label placer: labels at anchors, of given sizes, within a box, placed by a policy.
2
+
3
+ * :func:`spread`: positions along one axis, each label as near its wish as the others
4
+ allow, none overlapping, all within the box; when they do not fit, the labels of lowest
5
+ order are dropped;
6
+ * :func:`column`: :func:`spread` labels in a column at one x, with elbow leaders back to
7
+ their anchors (pie outside labels);
8
+ * :func:`drop`: labels kept in order, one overlapping a kept one dropped (value labels,
9
+ contour labels);
10
+ * :func:`fits`: whether a label fits a cell (heatmap labels: fit or drop);
11
+ * :func:`row`: names of features on rows (genome intervals): each name left of, right of
12
+ or inside its feature, clamped to the view, shortened where the whole name finds no
13
+ room, or dropped
14
+ (:func:`spot`: the first position a name can take, others aside);
15
+ * :func:`around`: labels of points, each beside its point or on rings further out (with a
16
+ leader), meeting no other label and no point; dropped where no room is left.
17
+
18
+ Labels are quads on the page (:func:`quad`); :func:`bar_labels` places value labels on
19
+ bars, histogram bins and pie wedges.
20
+ """
21
+
22
+ import bisect
23
+ import math
24
+ from typing import Any
25
+
26
+ from .canvas import Canvas
27
+ from . import kernels
28
+ from .color.values import ink_for
29
+ from .settings import one_of
30
+ from .text import Font, lines_run, measure
31
+
32
+ __all__ = ["check_labels", "bar_labels", "label_size", "PAD", "FIT", "spread", "column",
33
+ "drop", "fits", "row", "spot", "NAME_POSITIONS", "quad", "run_quad", "around"]
34
+
35
+ #: Gap between a bar's end and its label (em: × the label's font size).
36
+ PAD = 0.25
37
+ #: Room a label inside a bar keeps from its edges (em).
38
+ FIT = 0.2
39
+
40
+
41
+ def check_labels(v: Any) -> Any:
42
+ if v is True:
43
+ return "outside"
44
+ if v is False or v is None:
45
+ return False
46
+ return one_of("inside", "outside", "center")(v)
47
+
48
+
49
+ def label_size(canvas: Canvas, text: Any, font: Font, horizontal: bool,
50
+ pad: float | None = None) -> float:
51
+ """Room (px) a value label takes along a bar beyond its end: its width on horizontal
52
+ bars, its height on vertical ones, plus ``pad`` (px; default ``PAD`` em)."""
53
+ w, a, d = measure(canvas, text, font)
54
+ return (w if horizontal else a + d) + (PAD * font.size if pad is None else pad)
55
+
56
+
57
+ def bar_labels(canvas: Canvas, rects: list[tuple[float, float, float, float, bool]],
58
+ texts: list[Any], fills: list[Any], where: str, horizontal: bool,
59
+ font: Font, stacked: bool = False,
60
+ pad: float | None = None) -> list[tuple[Any, float, float, Any, str, str]]:
61
+ """Placed labels ``(text, x, y, color, ha, va)`` of bars ``(x0, y0, x1, y1,
62
+ negative)`` (page units): ``outside`` beyond the bar's end, ``inside`` just within it,
63
+ ``center`` in its middle. A label that does not fit inside its bar goes outside, or
64
+ is left out on a ``stacked`` bar (outside, it would cover the next part). ``pad``:
65
+ the gap to the bar's end (px; default ``PAD`` em)."""
66
+ if pad is None:
67
+ pad = PAD * font.size
68
+ fit = FIT * font.size
69
+ out: list[tuple[Any, float, float, Any, str, str]] = []
70
+ for (x0, y0, x1, y1, negative), text, fill in zip(rects, texts, fills):
71
+ if text is None or text == "":
72
+ continue
73
+ place = where
74
+ if where in ("inside", "center"):
75
+ w, a, d = measure(canvas, text, font)
76
+ room = (x1 - x0, y1 - y0)
77
+ along = (w if horizontal else a + d) + (pad if where == "inside" else fit)
78
+ across = (a + d if horizontal else w) + fit
79
+ if along > (room[0] if horizontal else room[1]) \
80
+ or across > (room[1] if horizontal else room[0]):
81
+ if stacked:
82
+ continue
83
+ place = "outside"
84
+ inner = place in ("inside", "center")
85
+ color = ink_for(fill) if inner else font.color
86
+ if place == "center":
87
+ out.append((text, (x0 + x1) / 2, (y0 + y1) / 2, color, "center", "center"))
88
+ continue
89
+ if horizontal:
90
+ end = x0 if negative else x1
91
+ o = (place == "outside") != negative
92
+ out.append((text, end + (pad if o else -pad), (y0 + y1) / 2, color,
93
+ "left" if o else "right", "center"))
94
+ else:
95
+ end = y1 if negative else y0
96
+ up = (place == "outside") != negative
97
+ if up:
98
+ out.append((text, (x0 + x1) / 2, end - pad, color, "center", "bottom"))
99
+ else:
100
+ out.append((text, (x0 + x1) / 2, end + pad, color, "center", "top"))
101
+ keep = drop([quad(canvas, t[0], font, *t[1:3], *t[4:6]) for t in out])
102
+ return [t for t, k in zip(out, keep) if k]
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # The placer
107
+ # ---------------------------------------------------------------------------
108
+
109
+ Quad = list[tuple[float, float]]
110
+
111
+
112
+ def run_quad(fonts: Any, r: Any) -> Quad | None:
113
+ """The corners of a placed run (:class:`~plot_io.text.Run`; a tuple of runs: the box
114
+ of their lines) on the page, rotated with it; ``None`` for nothing drawn."""
115
+ runs = [x for x in (r if isinstance(r, tuple) else (r,)) if x is not None]
116
+ if not runs:
117
+ return None
118
+ r0 = runs[0]
119
+ t = math.radians(r0.rotation)
120
+ c, s = math.cos(t), math.sin(t)
121
+ us, vs = [], []
122
+ for one in runs:
123
+ w, a, d = measure(fonts, one.content, one.font)
124
+ us += [one.dx, one.dx + w]
125
+ vs += [one.dy - a, one.dy + d]
126
+ return [(r0.x + u * c + v * s, r0.y - u * s + v * c)
127
+ for u, v in ((min(us), min(vs)), (max(us), min(vs)), (max(us), max(vs)),
128
+ (min(us), max(vs)))]
129
+
130
+
131
+ def quad(fonts: Any, text: Any, font: Font, x: float, y: float, ha: str = "left",
132
+ va: str = "baseline", angle: float = 0.0) -> Quad | None:
133
+ """The corners of ``text`` anchored at ``(x, y)`` as drawn (lines split at ``\\n``,
134
+ rotated by ``angle`` degrees about the anchor)."""
135
+ return run_quad(fonts, lines_run(fonts, text, x, y, font, ha, va, angle))
136
+
137
+
138
+ def _overlap(p: Quad, q: Quad) -> bool:
139
+ """Whether convex quads ``p`` and ``q`` overlap (separating axes: their edges)."""
140
+ for poly in (p, q):
141
+ for i in range(4):
142
+ ax = poly[(i + 1) % 4][1] - poly[i][1]
143
+ ay = poly[i][0] - poly[(i + 1) % 4][0]
144
+ a = [x * ax + y * ay for x, y in p]
145
+ b = [x * ax + y * ay for x, y in q]
146
+ if min(max(a), max(b)) <= max(min(a), min(b)):
147
+ return False
148
+ return True
149
+
150
+
151
+ def drop(quads: list[Quad | None], order: list[float] | None = None) -> list[bool]:
152
+ """Which labels to keep: taken by decreasing ``order`` (default: as given), a label
153
+ overlapping one kept before it is dropped."""
154
+ n = len(quads)
155
+ idx = sorted(range(n), key=lambda i: -order[i]) if order is not None else range(n)
156
+ keep = [False] * n
157
+ cell = 64.0
158
+ grid: dict[tuple[int, int], list[int]] = {}
159
+ for i in idx:
160
+ q = quads[i]
161
+ if q is None:
162
+ continue
163
+ xs, ys = [p[0] for p in q], [p[1] for p in q]
164
+ if not all(math.isfinite(v) for v in xs + ys):
165
+ continue
166
+ cells = [(gx, gy) for gx in range(int(min(xs) // cell), int(max(xs) // cell) + 1)
167
+ for gy in range(int(min(ys) // cell), int(max(ys) // cell) + 1)]
168
+ near = {j for c in cells for j in grid.get(c, ())}
169
+ if any(_overlap(q, quads[j]) for j in near): # type: ignore[arg-type]
170
+ continue
171
+ keep[i] = True
172
+ for c in cells:
173
+ grid.setdefault(c, []).append(i)
174
+ return keep
175
+
176
+
177
+ def spread(wants: list[float], sizes: list[float], lo: float, hi: float,
178
+ order: list[float] | None = None) -> list[float | None]:
179
+ """Centres along one axis of labels wanting theirs at ``wants`` and taking ``sizes``
180
+ (their extent and the gap after them): in the order of their wishes, none
181
+ overlapping, all within ``lo``–``hi``, each as near its wish as the others allow. When
182
+ they do not fit, the labels of lowest ``order`` (default: the last) are dropped
183
+ (``None``)."""
184
+ n = len(wants)
185
+ keep = set(range(n))
186
+ rank = sorted(range(n), key=lambda i: order[i] if order is not None else -i)
187
+ total = sum(sizes)
188
+ for i in rank:
189
+ if total <= hi - lo + 1e-9:
190
+ break
191
+ keep.discard(i)
192
+ total -= sizes[i]
193
+ seq = sorted(keep, key=lambda i: (wants[i], i))
194
+ pos: dict[int, float] = {}
195
+ edge = lo
196
+ for i in seq: # down from the top
197
+ pos[i] = max(wants[i], edge + sizes[i] / 2)
198
+ edge = pos[i] + sizes[i] / 2
199
+ edge = hi
200
+ for i in reversed(seq): # up from the bottom
201
+ pos[i] = min(pos[i], edge - sizes[i] / 2)
202
+ edge = pos[i] - sizes[i] / 2
203
+ return [pos.get(i) for i in range(n)]
204
+
205
+
206
+ def column(anchors: list[tuple[float, float]], wants: list[float], sizes: list[float],
207
+ x: float, elbow: float, lo: float, hi: float, order: list[float] | None = None
208
+ ) -> list[tuple[float, tuple[float, ...]] | None]:
209
+ """Labels in a column at ``x``: per label its y (:func:`spread` within ``lo``–``hi``)
210
+ and an elbow leader from its anchor through the point ``elbow`` px before the column
211
+ at the label's height; ``None``: dropped."""
212
+ ys = spread(wants, sizes, lo, hi, order)
213
+ out: list[tuple[float, tuple[float, ...]] | None] = []
214
+ for (ax, ay), y in zip(anchors, ys):
215
+ if y is None:
216
+ out.append(None)
217
+ continue
218
+ kx = x - elbow if x >= ax else x + elbow
219
+ end = x - 2.0 if x >= ax else x + 2.0
220
+ out.append((y, (ax, ay, kx, y, end, y)))
221
+ return out
222
+
223
+
224
+ def fits(fonts: Any, text: Any, font: Font, w: float, h: float, pad: float) -> bool:
225
+ """Whether ``text`` with ``pad`` px of room on each axis fits a cell ``w`` × ``h``."""
226
+ tw, a, d = measure(fonts, text, font)
227
+ return tw + pad <= abs(w) and a + d + pad <= abs(h)
228
+
229
+
230
+ # ---------------------------------------------------------------------------
231
+ # Names on rows
232
+ # ---------------------------------------------------------------------------
233
+
234
+ #: The positions ``name_position`` tries, in order.
235
+ NAME_POSITIONS = {"auto": ("left", "right", "inside"), "left": ("left",),
236
+ "right": ("right",), "inside": ("inside",)}
237
+
238
+ #: A placed name: its anchor x, its alignment (``"left"``, ``"right"``, ``"center"``)
239
+ #: and where it went (``"left"``, ``"right"``, ``"inside"``; ``"edge"``: at the view's
240
+ #: edge, over the visible part of a feature crossing it).
241
+ Spot = tuple[float, str, str]
242
+
243
+
244
+ def _candidates(a: float, b: float, w: float, lo: float, hi: float, gap: float,
245
+ positions: tuple[str, ...]) -> list[tuple[float, float, Spot, bool]]:
246
+ """The spans ``(s, e)`` a name ``w`` wide may take beside feature ``[a, b]`` within the
247
+ view ``lo``–``hi``, in the order of ``positions``, with its spot and whether it lies
248
+ over the feature. A feature crossing an edge is named at that edge, over its visible
249
+ part when the name leaves some of it uncovered."""
250
+ out: list[tuple[float, float, Spot, bool]] = []
251
+ for p in positions:
252
+ if p == "left":
253
+ if a - gap - w >= lo:
254
+ out.append((a - gap - w, a - gap, (a - gap, "right", "left"), False))
255
+ elif a < lo < b and lo + w + gap <= min(b, hi):
256
+ out.append((lo, lo + w, (lo, "left", "edge"), True))
257
+ elif p == "right":
258
+ if b + gap + w <= hi:
259
+ out.append((b + gap, b + gap + w, (b + gap, "left", "right"), False))
260
+ elif a < hi < b and hi - w - gap >= max(a, lo):
261
+ out.append((hi - w, hi, (hi, "right", "edge"), True))
262
+ else:
263
+ va, vb = max(a, lo), min(b, hi)
264
+ if vb - va >= w + 2 * gap:
265
+ c = (va + vb) / 2
266
+ out.append((c - w / 2, c + w / 2, (c, "center", "inside"), True))
267
+ return out
268
+
269
+
270
+ def spot(a: float, b: float, w: float, lo: float, hi: float, gap: float,
271
+ positions: tuple[str, ...]) -> tuple[float, float] | None:
272
+ """The footprint of feature ``[a, b]`` with its name ``w`` wide at the first position
273
+ it can take in the view (other features aside), a ``gap`` beyond the name; ``None``:
274
+ the name fits nowhere."""
275
+ c = _candidates(a, b, w, lo, hi, gap, positions)
276
+ if not c:
277
+ return None
278
+ s, e = c[0][:2]
279
+ return min(a, s - gap), max(b, e + gap)
280
+
281
+
282
+ def row(spans: list[tuple[float, float]], widths: list[float | None], rows: list[int],
283
+ lo: float, hi: float, gap: float, positions: tuple[str, ...],
284
+ order: list[float] | None = None,
285
+ taken: dict[int, tuple[float, float]] | None = None,
286
+ cut: Any = None) -> list[Spot | None]:
287
+ """Names of features ``spans`` (px) on ``rows``: per feature its :data:`Spot` or
288
+ ``None`` (no name: width ``None``, or no room). Features are taken by decreasing
289
+ ``order`` (default: by start); each takes the first of ``positions`` (see
290
+ :func:`_candidates`) whose span, ``gap`` apart, meets no other feature of its row, no
291
+ name placed before it and no span of ``taken`` (per row, a span kept free).
292
+
293
+ ``cut``: names shortened where the whole name finds no room, after every whole name
294
+ of the row is placed. An object with ``steps(i)`` (the shortenings of name ``i``,
295
+ shortest first; 0: none) and ``width(i, k)`` (its width shortened by ``k``, 1 the
296
+ shortest); the longest that finds room is kept and recorded as ``cut.kept[i] = k``."""
297
+ n = len(spans)
298
+ out: list[Spot | None] = [None] * n
299
+ by_row: dict[int, list[int]] = {}
300
+ for i in sorted(range(n), key=lambda i: (spans[i][0], i)):
301
+ by_row.setdefault(rows[i], []).append(i)
302
+ for r, idx in by_row.items():
303
+ lefts = [spans[i][0] for i in idx]
304
+ reach: list[float] = [] # the rightmost end so far, by start
305
+ for i in idx:
306
+ reach.append(max(spans[i][1], reach[-1]) if reach else spans[i][1])
307
+ at = {i: k for k, i in enumerate(idx)}
308
+ name_lo: list[float] = [] # names placed: disjoint, by start
309
+ name_hi: list[float] = []
310
+ if taken and r in taken:
311
+ name_lo.append(taken[r][0])
312
+ name_hi.append(taken[r][1])
313
+
314
+ def free(i: int, w: float) -> tuple[float, float, Spot] | None:
315
+ """The first place name ``i``, ``w`` wide, can take in the row."""
316
+ k = at[i]
317
+ for s, e, place, over in _candidates(*spans[i], w, lo, hi, gap, positions):
318
+ if over: # others than the feature itself
319
+ hit = (k > 0 and reach[k - 1] > s - gap) \
320
+ or (k + 1 < len(idx) and lefts[k + 1] < e + gap)
321
+ else:
322
+ j = bisect.bisect_left(lefts, e + gap)
323
+ hit = j > 0 and reach[j - 1] > s - gap
324
+ if hit:
325
+ continue
326
+ p = bisect.bisect_left(name_lo, s)
327
+ if (p and name_hi[p - 1] > s - gap) \
328
+ or (p < len(name_lo) and name_lo[p] < e + gap):
329
+ continue
330
+ return s, e, place
331
+ return None
332
+
333
+ def take(i: int, got: tuple[float, float, Spot]) -> None:
334
+ s, e, place = got
335
+ p = bisect.bisect_left(name_lo, s)
336
+ name_lo.insert(p, s)
337
+ name_hi.insert(p, e)
338
+ out[i] = place
339
+
340
+ seq = idx if order is None else sorted(idx, key=lambda i: (-order[i], spans[i][0]))
341
+ missed = []
342
+ for i in seq:
343
+ w = widths[i]
344
+ if w is None:
345
+ continue
346
+ got = free(i, w)
347
+ if got is None:
348
+ missed.append(i)
349
+ else:
350
+ take(i, got)
351
+ for i in missed if cut is not None else ():
352
+ hi_k = cut.steps(i)
353
+ if not hi_k or free(i, cut.width(i, 1)) is None:
354
+ continue
355
+ lo_k, best = 1, 1 # the longest shortening with room
356
+ while lo_k < hi_k:
357
+ mid = (lo_k + hi_k + 1) // 2
358
+ if free(i, cut.width(i, mid)) is not None:
359
+ lo_k, best = mid, mid
360
+ else:
361
+ hi_k = mid - 1
362
+ got = free(i, cut.width(i, best))
363
+ if got is not None:
364
+ take(i, got)
365
+ cut.kept[i] = best
366
+ return out
367
+
368
+
369
+ # ---------------------------------------------------------------------------
370
+ # Labels of points
371
+ # ---------------------------------------------------------------------------
372
+
373
+ #: Directions ``(dx, dy)`` of the positions tried beside a point, in order: right, left,
374
+ #: above, below, then the diagonals.
375
+ _BESIDE = ((1, 0), (-1, 0), (0, -1), (0, 1), (1, -1), (-1, -1), (1, 1), (-1, 1))
376
+ #: Angles tried on each ring further out.
377
+ _RING = 16
378
+
379
+ Box = tuple[float, float, float, float]
380
+
381
+
382
+ class _Grid:
383
+ """Boxes or discs hashed by square cells of ``cell`` px."""
384
+
385
+ def __init__(self, cell: float) -> None:
386
+ self.cell = cell
387
+ self.cells: dict[tuple[int, int], list[int]] = {}
388
+
389
+ def span(self, x0: float, y0: float, x1: float, y1: float) -> Any:
390
+ c = self.cell
391
+ return [(i, j) for i in range(int(x0 // c), int(x1 // c) + 1)
392
+ for j in range(int(y0 // c), int(y1 // c) + 1)]
393
+
394
+ def near(self, x0: float, y0: float, x1: float, y1: float) -> set[int]:
395
+ cells = self.cells
396
+ return {k for key in self.span(x0, y0, x1, y1) for k in cells.get(key, ())}
397
+
398
+
399
+ class _Points:
400
+ """Points in columns ``cell`` px wide, each sorted by y (sorted by the kernels): the
401
+ points near a box by bisection."""
402
+
403
+ def __init__(self, xs: Any, ys: Any, cell: float) -> None:
404
+ self.cell = cell
405
+ self.cols: dict[int, tuple[Any, Any]] = {}
406
+ order = kernels.argsort(xs)
407
+ sx = kernels.take(xs, order)
408
+ n, i = len(sx), 0
409
+ while i < n and not math.isfinite(sx[i]): # NaN and -inf first
410
+ i += 1
411
+ while i < n and math.isfinite(sx[i]):
412
+ c = math.floor(sx[i] / cell)
413
+ j = max(bisect.bisect_left(sx, (c + 1) * cell, i), i + 1)
414
+ idx = order[i:j]
415
+ cy = kernels.take(ys, idx)
416
+ oy = kernels.argsort(cy)
417
+ col_y = kernels.take(cy, oy)
418
+ k = 0
419
+ while k < len(col_y) and col_y[k] != col_y[k]:
420
+ k += 1
421
+ self.cols[c] = (kernels.take(kernels.take(xs, idx), oy)[k:], col_y[k:])
422
+ i = j
423
+
424
+ def hit(self, x0: float, y0: float, x1: float, y1: float, r: float) -> bool:
425
+ """Whether a disc of radius ``r`` about a point meets box ``(x0, y0, x1, y1)``."""
426
+ cell, r2 = self.cell, r * r
427
+ for c in range(math.floor((x0 - r) / cell - 1e-9),
428
+ math.floor((x1 + r) / cell + 1e-9) + 1):
429
+ col = self.cols.get(c)
430
+ if col is None:
431
+ continue
432
+ cx, cy = col
433
+ for k in range(bisect.bisect_left(cy, y0 - r), bisect.bisect_right(cy, y1 + r)):
434
+ px, py = cx[k], cy[k]
435
+ dx = x0 - px if px < x0 else px - x1 if px > x1 else 0.0
436
+ dy = y0 - py if py < y0 else py - y1 if py > y1 else 0.0
437
+ if dx * dx + dy * dy <= r2:
438
+ return True
439
+ return False
440
+
441
+
442
+ def around(points: tuple[Any, Any], radius: float, picks: list[int],
443
+ sizes: list[tuple[float, float]], box: Box, gap: float, reach: float,
444
+ taken: list[Box] | None = None) -> list[tuple[float, float, bool] | None]:
445
+ """Centres of the labels of points ``points`` (page ``(xs, ys)``; discs of
446
+ ``radius``): for each of ``picks`` (point indices, in priority order) with its label
447
+ of ``sizes`` ``(w, h)``, ``(x, y, leader)`` or ``None`` (no room). A label takes the
448
+ first free position of: 8 beside its point (``gap`` px from its disc), then rings every
449
+ half label height out to ``reach`` px from it, 16 angles each (those get ``leader``: a
450
+ line from the point to the nearest point of the label's box). Free: within ``box``,
451
+ meeting no label or leader placed before it, no box of ``taken`` (labels placed are
452
+ added to it) and no point's disc, its leader crossing no label and no leader.
453
+ Deterministic."""
454
+ xs, ys = points
455
+ hmax = max((h for _, h in sizes), default=0.0)
456
+ cell = max(hmax, 2 * radius, 4.0)
457
+ bx0, by0, bx1, by1 = box
458
+ pts = _Points(xs, ys, cell)
459
+ placed = taken if taken is not None else []
460
+ labels = _Grid(cell)
461
+ for k, b in enumerate(placed):
462
+ for key in labels.span(*b):
463
+ labels.cells.setdefault(key, []).append(k)
464
+ lines: list[Box] = [] # leaders placed
465
+ leads = _Grid(cell)
466
+
467
+ lcells, dcells = labels.cells, leads.cells
468
+
469
+ def free(x0: float, y0: float, x1: float, y1: float) -> bool:
470
+ if x0 < bx0 or y0 < by0 or x1 > bx1 or y1 > by1:
471
+ return False
472
+ if pts.hit(x0, y0, x1, y1, radius):
473
+ return False
474
+ j0, j1 = int(y0 // cell), int(y1 // cell) + 1
475
+ for i in range(int(x0 // cell), int(x1 // cell) + 1):
476
+ for j in range(j0, j1):
477
+ for k in lcells.get((i, j), ()):
478
+ a0, b0, a1, b1 = placed[k]
479
+ if a0 < x1 and x0 < a1 and b0 < y1 and y0 < b1:
480
+ return False
481
+ for k in dcells.get((i, j), ()):
482
+ if _crosses(lines[k], (x0, y0, x1, y1)):
483
+ return False
484
+ return True
485
+
486
+ out: list[tuple[float, float, bool] | None] = []
487
+ d = radius + gap
488
+ for i, (w, h) in zip(picks, sizes):
489
+ x, y = xs[i], ys[i]
490
+ got = None
491
+ if math.isfinite(x) and math.isfinite(y):
492
+ hw, hh = w / 2, h / 2
493
+ for cx, cy, ring in _spots(x, y, d, hw, hh, reach):
494
+ if not free(cx - hw, cy - hh, cx + hw, cy + hh):
495
+ continue
496
+ line = (x, y, min(max(x, cx - hw), cx + hw), min(max(y, cy - hh), cy + hh))
497
+ if ring and (any(_crosses(line, placed[k]) for k in labels.near(*_bounds(line)))
498
+ or any(_meet(line, lines[k]) for k in leads.near(*_bounds(line)))):
499
+ continue
500
+ got = (cx, cy, ring)
501
+ if ring:
502
+ for key in leads.span(*_bounds(line)):
503
+ leads.cells.setdefault(key, []).append(len(lines))
504
+ lines.append(line)
505
+ break
506
+ out.append(got)
507
+ if got is not None:
508
+ b = (got[0] - w / 2, got[1] - h / 2, got[0] + w / 2, got[1] + h / 2)
509
+ for key in labels.span(*b):
510
+ labels.cells.setdefault(key, []).append(len(placed))
511
+ placed.append(b)
512
+ return out
513
+
514
+
515
+ def _bounds(line: Box) -> Box:
516
+ x0, y0, x1, y1 = line
517
+ return min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)
518
+
519
+
520
+ def _meet(p: Box, q: Box) -> bool:
521
+ """Whether segments ``p`` and ``q`` cross (touching ends do not)."""
522
+ def side(ax: float, ay: float, bx: float, by: float, cx: float, cy: float) -> float:
523
+ return (bx - ax) * (cy - ay) - (by - ay) * (cx - ax)
524
+ d1 = side(*q[:2], *q[2:], *p[:2])
525
+ d2 = side(*q[:2], *q[2:], *p[2:])
526
+ d3 = side(*p[:2], *p[2:], *q[:2])
527
+ d4 = side(*p[:2], *p[2:], *q[2:])
528
+ return d1 * d2 < 0 and d3 * d4 < 0
529
+
530
+
531
+ def _crosses(line: Box, b: Box) -> bool:
532
+ """Whether segment ``line`` passes through the inside of box ``b`` (Liang–Barsky)."""
533
+ x0, y0, x1, y1 = line
534
+ dx, dy = x1 - x0, y1 - y0
535
+ t0, t1 = 0.0, 1.0
536
+ for p, q in ((-dx, x0 - b[0]), (dx, b[2] - x0), (-dy, y0 - b[1]), (dy, b[3] - y0)):
537
+ if p == 0:
538
+ if q <= 0:
539
+ return False
540
+ else:
541
+ t = q / p
542
+ if p < 0:
543
+ t0 = max(t0, t)
544
+ else:
545
+ t1 = min(t1, t)
546
+ if t0 >= t1:
547
+ return False
548
+ return True
549
+
550
+
551
+ def _spots(x: float, y: float, d: float, hw: float, hh: float, reach: float) -> Any:
552
+ """The centres a label ``2·hw`` × ``2·hh`` tries around point ``(x, y)``: beside it,
553
+ its box ``d`` px away, then on rings (``True``) every ``hh`` px out to ``reach``."""
554
+ for dx, dy in _BESIDE:
555
+ if dx and dy:
556
+ yield x + dx * (d * 0.7071 + hw), y + dy * (d * 0.7071 + hh), False
557
+ else:
558
+ yield x + dx * (d + hw), y + dy * (d + hh), False
559
+ step = max(hh, 1.0)
560
+ r = d + step
561
+ while r <= reach:
562
+ for k in range(_RING):
563
+ t = 2 * math.pi * k / _RING
564
+ c, s = math.cos(t), -math.sin(t)
565
+ yield x + c * (r + hw), y + s * (r + hh), True
566
+ r += step
@@ -0,0 +1,100 @@
1
+ """Layers: plot types as ``stat → position → geom`` presets.
2
+
3
+ * :mod:`~plot_io.layers.base`: :class:`Series`, the model of a call (data and settings);
4
+ * :mod:`~plot_io.layers.core`: :class:`Layer`, :class:`Call`, :class:`Table`;
5
+ * :mod:`~plot_io.layers.stats`, :mod:`~plot_io.layers.positions`,
6
+ :mod:`~plot_io.layers.geoms`: the three parts of a layer;
7
+ * :mod:`~plot_io.layers.pipeline`: compute, positions and color training per plot;
8
+ * :mod:`~plot_io.layers.paint`: colors, mapped values and markers;
9
+ * :mod:`~plot_io.layers.context`: the drawing context and the orientation swap.
10
+
11
+ The plot types themselves are presets in :mod:`plot_io.presets` (and
12
+ :mod:`plot_io.genome`); :func:`add_layer` (``registry.layers.add``) adds one as a
13
+ method of ``Plot`` (a layer in the plot), of ``Figure`` (a new plot holding it) and of facet
14
+ sets (a layer per panel). The built-in types' methods are in :mod:`plot_io._methods`
15
+ (generated, typed); a type added by a user is attached to the classes.
16
+ """
17
+
18
+ from typing import Any, Callable
19
+
20
+ from .base import Series
21
+ from .core import Call, Entry, Layer, Table
22
+
23
+ __all__ = ["Series", "Layer", "Call", "Entry", "Table", "add_layer", "layer_types", "layer_type",
24
+ "figure_call"]
25
+
26
+ _TYPES: dict[str, Any] = {}
27
+
28
+
29
+ def add_layer(name: str, factory: Callable[..., Any]) -> None:
30
+ """Add ``plot.<name>(…)``: it calls ``factory(…, plot=plot)``; ``figure.<name>(…)``:
31
+ the same in a new plot of the figure (a track in a tracks layout), which takes the plot
32
+ settings ``factory`` lacks (and ``title``); ``facets.<name>(…)``: one per panel. A
33
+ factory that is a :class:`Series` subclass is also a style class (``"Name.setting"``
34
+ style keys)."""
35
+ from .._methods import LAYERS
36
+ from ..facets import PanelSet
37
+ from ..figure import Figure
38
+ from ..plot import Plot
39
+ if not name.isidentifier() or name.startswith("_"):
40
+ raise ValueError(f"layer names are identifiers not starting with _, got {name!r}")
41
+ if name in LAYERS and name not in _TYPES: # a built-in type: its methods exist
42
+ _TYPES[name] = factory
43
+ return
44
+ for owner in (Plot, Figure, PanelSet):
45
+ if getattr(owner, name, None) is not None and name not in _TYPES:
46
+ raise ValueError(f"{owner.__name__} already has {name!r}")
47
+
48
+ def method(self: Any, *args: Any, **settings: Any) -> Any:
49
+ return factory(*args, plot=self, **settings)
50
+
51
+ def figure_method(self: Any, *args: Any, **settings: Any) -> Any:
52
+ return figure_call(self, factory, args, settings)
53
+
54
+ def facet_method(self: Any, *args: Any, **settings: Any) -> Any:
55
+ return self.call(name, args, settings)
56
+
57
+ from .base import call_signature
58
+ try:
59
+ sig = call_signature(factory, drop=("plot",), first=("self",))
60
+ except (TypeError, ValueError): # a callable inspect cannot read
61
+ sig = None
62
+ doc = getattr(factory, "__doc__", None)
63
+ first = doc.strip().splitlines()[0] if doc else f"A {name} layer."
64
+ for owner, fn, what in ((Plot, method, first),
65
+ (Figure, figure_method, f"A new plot holding: {first}"),
66
+ (PanelSet, facet_method, f"One per panel: {first}")):
67
+ fn.__name__ = name
68
+ fn.__qualname__ = f"{owner.__name__}.{name}"
69
+ fn.__doc__ = what
70
+ if sig is not None:
71
+ fn.__signature__ = sig # type: ignore[attr-defined]
72
+ setattr(owner, name, fn)
73
+ _TYPES[name] = factory
74
+
75
+
76
+ def figure_call(figure: Any, factory: Any, args: tuple[Any, ...],
77
+ settings: dict[str, Any]) -> Any:
78
+ """``factory(*args, **settings)`` in a new plot of ``figure`` (``figure.<name>(…)``),
79
+ which takes the plot settings among ``settings``; a failed call leaves no plot."""
80
+ before = list(figure._areas)
81
+ settings = dict(settings)
82
+ plot = figure._new_plot(factory, settings)
83
+ try:
84
+ return factory(*args, plot=plot, **settings)
85
+ except BaseException:
86
+ for k in [k for k in figure._areas if k not in before]:
87
+ del figure._areas[k]
88
+ raise
89
+
90
+
91
+ def layer_type(name: str) -> Any:
92
+ """The factory of layer ``name``."""
93
+ if name not in _TYPES:
94
+ raise ValueError(f"unknown layer {name!r} (registered: {', '.join(sorted(_TYPES))})")
95
+ return _TYPES[name]
96
+
97
+
98
+ def layer_types() -> dict[str, Any]:
99
+ """The registered layer names and their factories."""
100
+ return dict(_TYPES)