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/layers/geoms.py
ADDED
|
@@ -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()}
|