plot-io 0.12.0__py3-none-win_arm64.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.
- plot_io/__init__.py +73 -0
- plot_io/_methods.py +1691 -0
- plot_io/_native/__init__.py +199 -0
- plot_io/_native/__main__.py +197 -0
- plot_io/_native/_api.py +457 -0
- plot_io/_native/bin/windows-arm64.dll +0 -0
- plot_io/_native/kernels.py +335 -0
- plot_io/_native/manifest.json +49 -0
- plot_io/axis.py +1347 -0
- plot_io/boxes.py +1156 -0
- plot_io/canvas.py +389 -0
- plot_io/color/__init__.py +3 -0
- plot_io/color/data.py +1646 -0
- plot_io/color/maps.py +922 -0
- plot_io/color/values.py +295 -0
- plot_io/color/variants.py +1246 -0
- plot_io/colorbar.py +1426 -0
- plot_io/columns.py +614 -0
- plot_io/decorations.py +920 -0
- plot_io/defaults.py +809 -0
- plot_io/density.py +113 -0
- plot_io/ext.py +68 -0
- plot_io/facets.py +240 -0
- plot_io/figure.py +788 -0
- plot_io/formatters.py +97 -0
- plot_io/genome/__init__.py +17 -0
- plot_io/genome/adapters.py +356 -0
- plot_io/genome/combine.py +123 -0
- plot_io/genome/contacts.py +508 -0
- plot_io/genome/intervals.py +750 -0
- plot_io/genome/lanes.py +191 -0
- plot_io/genome/layout.py +184 -0
- plot_io/genome/links.py +364 -0
- plot_io/genome/pack.py +27 -0
- plot_io/genome/readers.py +145 -0
- plot_io/genome/regions.py +228 -0
- plot_io/genome/scale.py +349 -0
- plot_io/genome/series.py +344 -0
- plot_io/genome/signal.py +621 -0
- plot_io/genome/sites.py +782 -0
- plot_io/genome/sources.py +459 -0
- plot_io/genome/testing.py +161 -0
- plot_io/geometry.py +289 -0
- plot_io/groups.py +308 -0
- plot_io/guides.py +503 -0
- plot_io/helpers/__init__.py +17 -0
- plot_io/helpers/clustermap.py +202 -0
- plot_io/helpers/dendrogram.py +172 -0
- plot_io/helpers/grids.py +304 -0
- plot_io/helpers/qq.py +334 -0
- plot_io/helpers/raincloud.py +249 -0
- plot_io/helpers/tree.py +214 -0
- plot_io/helpers/volcano.py +505 -0
- plot_io/hints.py +125 -0
- plot_io/kernels.py +740 -0
- plot_io/labels.py +566 -0
- plot_io/layers/__init__.py +100 -0
- plot_io/layers/base.py +630 -0
- plot_io/layers/context.py +87 -0
- plot_io/layers/core.py +446 -0
- plot_io/layers/geoms.py +1362 -0
- plot_io/layers/grammar.py +586 -0
- plot_io/layers/paint.py +614 -0
- plot_io/layers/pipeline.py +170 -0
- plot_io/layers/positions.py +431 -0
- plot_io/layers/stats.py +1088 -0
- plot_io/layout.py +1381 -0
- plot_io/legend.py +605 -0
- plot_io/levels.py +509 -0
- plot_io/limits.py +124 -0
- plot_io/lines.py +42 -0
- plot_io/mapped.py +522 -0
- plot_io/markers.py +110 -0
- plot_io/markup.py +128 -0
- plot_io/numbers.py +354 -0
- plot_io/occupancy.py +295 -0
- plot_io/plot.py +597 -0
- plot_io/presets/__init__.py +51 -0
- plot_io/presets/_util.py +153 -0
- plot_io/presets/area.py +145 -0
- plot_io/presets/bar.py +261 -0
- plot_io/presets/box.py +400 -0
- plot_io/presets/catpoints.py +177 -0
- plot_io/presets/contour.py +353 -0
- plot_io/presets/curves.py +30 -0
- plot_io/presets/dots.py +355 -0
- plot_io/presets/ecdf.py +97 -0
- plot_io/presets/errorbar.py +106 -0
- plot_io/presets/errors.py +296 -0
- plot_io/presets/fill.py +74 -0
- plot_io/presets/heatmap.py +322 -0
- plot_io/presets/hexbin.py +198 -0
- plot_io/presets/hist.py +391 -0
- plot_io/presets/hist2d.py +87 -0
- plot_io/presets/kde.py +162 -0
- plot_io/presets/kde2d.py +147 -0
- plot_io/presets/line.py +143 -0
- plot_io/presets/pie.py +502 -0
- plot_io/presets/regression.py +115 -0
- plot_io/presets/scatter.py +194 -0
- plot_io/presets/stem.py +100 -0
- plot_io/presets/violin.py +323 -0
- plot_io/py.typed +0 -0
- plot_io/registry.py +82 -0
- plot_io/render.py +480 -0
- plot_io/rows.py +491 -0
- plot_io/scales.py +103 -0
- plot_io/seams.py +138 -0
- plot_io/settings.py +807 -0
- plot_io/shapes.py +66 -0
- plot_io/sharing.py +232 -0
- plot_io/sizemaps.py +97 -0
- plot_io/spec.py +240 -0
- plot_io/states.py +216 -0
- plot_io/styles/__init__.py +307 -0
- plot_io/styles/library.py +397 -0
- plot_io/tables.py +138 -0
- plot_io/text.py +412 -0
- plot_io/ticks.py +400 -0
- plot_io/times.py +287 -0
- plot_io/titles.py +225 -0
- plot_io/transform.py +114 -0
- plot_io/types.py +13604 -0
- plot_io/units.py +768 -0
- plot_io/validators.py +1029 -0
- plot_io/values.py +190 -0
- plot_io-0.12.0.dist-info/METADATA +215 -0
- plot_io-0.12.0.dist-info/RECORD +130 -0
- plot_io-0.12.0.dist-info/WHEEL +4 -0
- 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)
|