plot-io 0.12.0__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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-x86_64.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
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Heatmap: a grid of values drawn as colored cells (``Cells``), with optional cell labels
|
|
2
|
+
(``Text``)."""
|
|
3
|
+
|
|
4
|
+
import array
|
|
5
|
+
import math
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from vec_io.arrays import doubles
|
|
9
|
+
|
|
10
|
+
from .. import groups
|
|
11
|
+
from .. import kernels
|
|
12
|
+
from .._methods import HeatmapMixin
|
|
13
|
+
from ..color.values import color, ink_for
|
|
14
|
+
from ..layers.base import Series, common
|
|
15
|
+
from ..layers.core import Call, Layer, Table
|
|
16
|
+
from ..layers.geoms import GEOMS
|
|
17
|
+
from ..layers.paint import Paint, role
|
|
18
|
+
from ..numbers import NumberStyle, check_format, format_numbers
|
|
19
|
+
from ..mapped import color_axis_of, trained
|
|
20
|
+
from ..settings import Setting, boolean, one_of, table
|
|
21
|
+
from ..labels import FIT, fits
|
|
22
|
+
from ..text import font_of
|
|
23
|
+
from ..transform import settings_both as coord_settings
|
|
24
|
+
from ._util import columns_meta, gradient_entry, placed_text
|
|
25
|
+
|
|
26
|
+
__all__ = ["Heatmap", "Coords"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _labels(v: Any) -> Any:
|
|
30
|
+
if v == "all":
|
|
31
|
+
return "all"
|
|
32
|
+
return boolean(v)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Coords:
|
|
36
|
+
"""Cell coordinates along one axis: categories, centres or edges."""
|
|
37
|
+
|
|
38
|
+
def axis(self, call: Call, role: str) -> None:
|
|
39
|
+
"""What the call's axis ``role`` needs: categories, the extent, the coordinates
|
|
40
|
+
given (in the extent), integer ticks for indices."""
|
|
41
|
+
if self.categories is not None:
|
|
42
|
+
call.cats[role] = self.categories
|
|
43
|
+
call.extent_of[role] = self.extent
|
|
44
|
+
if self.kind in ("centers", "edges"):
|
|
45
|
+
call.extra[role] = self.values
|
|
46
|
+
elif self.kind == "index":
|
|
47
|
+
call.whole.add(role)
|
|
48
|
+
|
|
49
|
+
def __init__(self, given: Any, n: int, what: str) -> None:
|
|
50
|
+
self.n = n
|
|
51
|
+
self.categories: list[Any] | None = None
|
|
52
|
+
self.kind = "index"
|
|
53
|
+
self.values: array.array = array.array("d")
|
|
54
|
+
if given is None:
|
|
55
|
+
return
|
|
56
|
+
items = list(given)
|
|
57
|
+
if items and all(isinstance(v, str) for v in items):
|
|
58
|
+
if len(items) != n:
|
|
59
|
+
raise ValueError(f"{len(items)} {what} labels for {n} cells")
|
|
60
|
+
self.categories = items
|
|
61
|
+
self.kind = "category"
|
|
62
|
+
return
|
|
63
|
+
vals = doubles(given)
|
|
64
|
+
if len(vals) == n:
|
|
65
|
+
self.kind = "centers"
|
|
66
|
+
elif len(vals) == n + 1:
|
|
67
|
+
self.kind = "edges"
|
|
68
|
+
else:
|
|
69
|
+
raise ValueError(f"{what} needs {n} centres or {n + 1} edges, got {len(vals)}")
|
|
70
|
+
if any(not math.isfinite(v) for v in vals):
|
|
71
|
+
raise ValueError(f"{what} coordinates must be finite")
|
|
72
|
+
d = [b - a for a, b in zip(vals, vals[1:])]
|
|
73
|
+
if d and not (all(x > 0 for x in d) or all(x < 0 for x in d)):
|
|
74
|
+
raise ValueError(f"{what} coordinates must be monotonic")
|
|
75
|
+
self.values = vals
|
|
76
|
+
|
|
77
|
+
def edges_f(self, f: Any) -> list[float]:
|
|
78
|
+
"""Cell edges after the scale function ``f``."""
|
|
79
|
+
if self.kind in ("index", "category"):
|
|
80
|
+
return [i - 0.5 for i in range(self.n + 1)]
|
|
81
|
+
fv = [f(v) for v in self.values]
|
|
82
|
+
if any(not math.isfinite(v) for v in fv):
|
|
83
|
+
raise ValueError("heatmap coordinates outside the axis scale's domain")
|
|
84
|
+
if self.kind == "edges":
|
|
85
|
+
return fv
|
|
86
|
+
if len(fv) == 1:
|
|
87
|
+
return [fv[0] - 0.5, fv[0] + 0.5]
|
|
88
|
+
mids = [(a + b) / 2 for a, b in zip(fv, fv[1:])]
|
|
89
|
+
return [fv[0] - (mids[0] - fv[0])] + mids + [fv[-1] + (fv[-1] - mids[-1])]
|
|
90
|
+
|
|
91
|
+
def extent(self, scale: Any) -> tuple[int, float, float]:
|
|
92
|
+
if self.kind in ("index", "category"):
|
|
93
|
+
return self.n, -0.5, self.n - 0.5
|
|
94
|
+
e = self.edges_f(scale.f)
|
|
95
|
+
lo, hi = min(e), max(e)
|
|
96
|
+
return self.n, scale.inverse(lo), scale.inverse(hi)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _even(e: list[float]) -> bool:
|
|
100
|
+
if len(e) < 3:
|
|
101
|
+
return True
|
|
102
|
+
d0 = (e[-1] - e[0]) / (len(e) - 1)
|
|
103
|
+
return all(abs((b - a) - d0) <= 1e-6 * max(1.0, abs(d0)) for a, b in zip(e, e[1:]))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Heatmap(HeatmapMixin, Series):
|
|
107
|
+
"""A grid of values (rows first) as colored cells; ``x``/``y`` give cell labels,
|
|
108
|
+
centres or edges.
|
|
109
|
+
|
|
110
|
+
A 3-D ``(h, w, 3|4)`` grid is drawn as colors. ``x``/``y``: ``None`` (cell indices),
|
|
111
|
+
labels (a category axis), n cell centres or n + 1 edges (uneven spacing and any axis
|
|
112
|
+
scale allowed). ``color_map``, ``color_limits`` and ``color_scale`` configure the
|
|
113
|
+
series' `color axis <docs/api.md#6-color-and-size-axes>`_. ``value_labels=True`` writes each value, formatted by
|
|
114
|
+
``value_label_format`` (any `number format <docs/api.md#number-formats>`_; ``"auto"``: 3
|
|
115
|
+
significant digits, the same decimals for all cells), in black or white, whichever
|
|
116
|
+
contrasts more (at most 2,500 cells unless ``value_labels="all"``). ``kind``:
|
|
117
|
+
``image``, ``cells`` or ``smooth``.
|
|
118
|
+
|
|
119
|
+
Example::
|
|
120
|
+
|
|
121
|
+
import math
|
|
122
|
+
p = plt.Plot(width=240, height=160)
|
|
123
|
+
p.heatmap([[math.sin(i / 2 + 0.5) * math.cos(j / 3) for j in range(6)] for i in range(5)],
|
|
124
|
+
x=list("abcdef"), value_labels=True, colorbar=True)
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
SETTINGS = table(
|
|
128
|
+
**coord_settings(),
|
|
129
|
+
**common("color_map"),
|
|
130
|
+
kind=Setting(one_of("image", "cells", "smooth"),
|
|
131
|
+
doc="`image` (one image pixel per cell), `cells` (rectangles) or "
|
|
132
|
+
"`smooth` (interpolated)."),
|
|
133
|
+
nan_color=Setting(color, doc="Color of missing cells; default: the map's `bad` "
|
|
134
|
+
"(transparent)."),
|
|
135
|
+
origin=Setting(one_of("top", "bottom"),
|
|
136
|
+
doc="`top`: row 0 at the top (y axis inverted); `bottom`: at the bottom."),
|
|
137
|
+
value_labels=Setting(_labels,
|
|
138
|
+
doc="Write each cell's value (`\"all\"` above 2,500 cells)."),
|
|
139
|
+
value_label_format=Setting(check_format,
|
|
140
|
+
doc="Number format of cell labels (`auto`: 3 significant "
|
|
141
|
+
"digits, the same decimals for all cells)."),
|
|
142
|
+
**groups.TextStyle.embed("value_label_",
|
|
143
|
+
only=("font_size",),
|
|
144
|
+
docs={"font_size": "Font size of cell labels."}),
|
|
145
|
+
**common("name", "opacity", "z", "rasterize", "simplify", "font_size", "font_family",
|
|
146
|
+
"text_color"),
|
|
147
|
+
**groups.NumberFormat.embed(),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
DATA = {"values": "raw", "x": "raw", "y": "raw"}
|
|
151
|
+
|
|
152
|
+
def __init__(self, values: Any, x: Any = None, y: Any = None, plot: Any = None,
|
|
153
|
+
**settings: Any) -> None:
|
|
154
|
+
values, x, y = self.cols["values"], self.cols["x"], self.cols["y"]
|
|
155
|
+
g = kernels.grid(values)
|
|
156
|
+
if g is not None:
|
|
157
|
+
h, w, k, flat = g
|
|
158
|
+
self.h, self.w, self.k = h, w, k
|
|
159
|
+
self.flat = flat
|
|
160
|
+
else:
|
|
161
|
+
rows = [list(r) for r in values]
|
|
162
|
+
if not rows:
|
|
163
|
+
raise ValueError("a heatmap needs at least one row")
|
|
164
|
+
w = len(rows[0])
|
|
165
|
+
if any(len(r) != w for r in rows):
|
|
166
|
+
raise ValueError("heatmap rows must have the same length")
|
|
167
|
+
if w and rows[0] and isinstance(rows[0][0], (list, tuple)):
|
|
168
|
+
self.k = len(rows[0][0])
|
|
169
|
+
self.flat = array.array("d", [float(c) for r in rows for cell in r for c in cell])
|
|
170
|
+
else:
|
|
171
|
+
self.k = 1
|
|
172
|
+
self.flat = doubles([v for r in rows for v in r])
|
|
173
|
+
self.h, self.w = len(rows), w
|
|
174
|
+
if self.k not in (1, 3, 4):
|
|
175
|
+
raise ValueError("cells must be values or (r, g, b[, a]) colors")
|
|
176
|
+
self.xc = Coords(x, self.w, "x")
|
|
177
|
+
self.yc = Coords(y, self.h, "y")
|
|
178
|
+
super().__init__(plot=plot, **settings)
|
|
179
|
+
|
|
180
|
+
def _bar_source(self) -> Any:
|
|
181
|
+
return self if self.k == 1 else None # RGB cells: no color axis
|
|
182
|
+
|
|
183
|
+
def _grid(self) -> tuple[Any, int, int, int, Coords, Coords]:
|
|
184
|
+
"""``(flat values, rows, columns, channels, x coordinates, y coordinates)``."""
|
|
185
|
+
return self.flat, self.h, self.w, self.k, self.xc, self.yc
|
|
186
|
+
|
|
187
|
+
def _inverts(self) -> bool:
|
|
188
|
+
return self.resolve("origin") == "top"
|
|
189
|
+
|
|
190
|
+
def layers(self) -> Call:
|
|
191
|
+
call = Call(self)
|
|
192
|
+
flat, h, w, k, xc, yc = self._grid()
|
|
193
|
+
axis = color_axis_of(self) if self.k == 1 else None
|
|
194
|
+
cmap = axis.color_map() if axis is not None else None
|
|
195
|
+
key = id(self)
|
|
196
|
+
|
|
197
|
+
def page_edges(ctx: Any, c: Coords, role: str) -> list[float]:
|
|
198
|
+
state = ctx.x if role == "x" else ctx.y
|
|
199
|
+
mapper = ctx.xmap if role == "x" else ctx.ymap
|
|
200
|
+
return [mapper.position(e) for e in c.edges_f(state.f)]
|
|
201
|
+
|
|
202
|
+
def grid(ctx: Any) -> dict[str, Any]:
|
|
203
|
+
def make() -> dict[str, Any]:
|
|
204
|
+
xe = page_edges(ctx, xc, "x")
|
|
205
|
+
ye = page_edges(ctx, yc, "y")
|
|
206
|
+
flip_rows = ye[0] > ye[-1] # row 0 must come first from the top
|
|
207
|
+
flip_cols = xe[0] > xe[-1]
|
|
208
|
+
if flip_rows:
|
|
209
|
+
ye = ye[::-1]
|
|
210
|
+
if flip_cols:
|
|
211
|
+
xe = xe[::-1]
|
|
212
|
+
if axis is not None:
|
|
213
|
+
st = trained(axis)
|
|
214
|
+
assert st.scale is not None
|
|
215
|
+
flo, fhi = st.flo, st.fhi
|
|
216
|
+
fn = st.scale.function
|
|
217
|
+
f = kernels.scaled(flat, 1.0, 0.0, fn, **st.scale.options) if fn else flat
|
|
218
|
+
if fn and (math.inf in flat or -math.inf in flat):
|
|
219
|
+
for i, v in enumerate(flat): # infinities: the map's extremes
|
|
220
|
+
if v in (math.inf, -math.inf):
|
|
221
|
+
f[i] = v
|
|
222
|
+
rows: list[Any] = [f[r * w:(r + 1) * w] for r in range(h)]
|
|
223
|
+
else:
|
|
224
|
+
flo = fhi = 0.0
|
|
225
|
+
rows = [[tuple(flat[(r * w + c) * k:(r * w + c + 1) * k]) for c in range(w)]
|
|
226
|
+
for r in range(h)]
|
|
227
|
+
order = list(range(h))[::-1] if flip_rows else list(range(h))
|
|
228
|
+
rows = [rows[i] for i in order]
|
|
229
|
+
if flip_cols:
|
|
230
|
+
rows = [r[::-1] for r in rows]
|
|
231
|
+
return {"rows": rows, "xe": xe, "ye": ye, "flo": flo, "fhi": fhi}
|
|
232
|
+
return memo_grid(ctx, key, make)
|
|
233
|
+
|
|
234
|
+
if h and w: # an empty grid draws nothing
|
|
235
|
+
call.layers.append(Layer(GEOMS["cells"], Table(),
|
|
236
|
+
paint=Paint(self, "heatmap.cells", grid=grid, cmap=cmap),
|
|
237
|
+
stat="identity", occupy="all", key=False))
|
|
238
|
+
if self.resolve("value_labels") and h and w:
|
|
239
|
+
call.layers.append(placed_text(self, key, lambda ctx: self._label_items(ctx, grid(ctx),
|
|
240
|
+
axis),
|
|
241
|
+
lambda ctx: font_of(
|
|
242
|
+
self, self.resolve("value_label_font_size")),
|
|
243
|
+
overlay=False))
|
|
244
|
+
name = self.resolve("name")
|
|
245
|
+
if name is not None:
|
|
246
|
+
call.entries.append(gradient_entry(
|
|
247
|
+
self, name, lambda: axis.color_map() if axis is not None else None))
|
|
248
|
+
if axis is not None:
|
|
249
|
+
call.color_sources = [(axis, flat)]
|
|
250
|
+
xc.axis(call, "x")
|
|
251
|
+
yc.axis(call, "y")
|
|
252
|
+
if self._inverts():
|
|
253
|
+
call.invert = {"y"}
|
|
254
|
+
call.exact = {"x", "y"}
|
|
255
|
+
columns_meta(call, {"values": "x"})
|
|
256
|
+
return call
|
|
257
|
+
|
|
258
|
+
def _label_items(self, ctx: Any, g: dict[str, Any], axis: Any) -> list[Any]:
|
|
259
|
+
_, h, w, _, _, _ = self._grid()
|
|
260
|
+
if h * w > 2500 and self.resolve("value_labels") != "all":
|
|
261
|
+
raise ValueError(f"{h * w} cells is too many for labels "
|
|
262
|
+
"(use value_labels=\"all\" to force)")
|
|
263
|
+
fmt = self.resolve("value_label_format")
|
|
264
|
+
rows, xe, ye, flo, fhi = g["rows"], g["xe"], g["ye"], g["flo"], g["fhi"]
|
|
265
|
+
cmap = axis.color_map() if axis is not None else None
|
|
266
|
+
scale = trained(axis).scale if axis is not None else None
|
|
267
|
+
fn = scale.inverse if scale is not None else None
|
|
268
|
+
cells = [(r, c, v) for r, row in enumerate(rows) for c, v in enumerate(row)]
|
|
269
|
+
if cmap is not None:
|
|
270
|
+
cells = [(r, c, v) for r, c, v in cells if v == v]
|
|
271
|
+
style = NumberStyle.of(self)
|
|
272
|
+
if style.precision is None and fmt == "auto":
|
|
273
|
+
style.precision = 3
|
|
274
|
+
texts = format_numbers([fn(v) if fn else v for _, _, v in cells], fmt, style)
|
|
275
|
+
elif callable(fmt):
|
|
276
|
+
texts = [fmt(v) for _, _, v in cells]
|
|
277
|
+
elif fmt not in ("auto", "percent", "sci", "eng", "si", "int"):
|
|
278
|
+
texts = [fmt.format(*v[:3]) for _, _, v in cells]
|
|
279
|
+
else:
|
|
280
|
+
texts = ["" for _ in cells]
|
|
281
|
+
font = font_of(self, self.resolve("value_label_font_size"))
|
|
282
|
+
fit = FIT * font.size
|
|
283
|
+
out = []
|
|
284
|
+
for (r, c, v), text in zip(cells, texts):
|
|
285
|
+
if not fits(ctx.canvas, text, font, xe[c + 1] - xe[c], ye[r + 1] - ye[r], fit):
|
|
286
|
+
continue # a label that does not fit is left out
|
|
287
|
+
if cmap is None:
|
|
288
|
+
col = v
|
|
289
|
+
elif v != v:
|
|
290
|
+
col = cmap.bad
|
|
291
|
+
else:
|
|
292
|
+
col = cmap((v - flo) / ((fhi - flo) or 1.0))
|
|
293
|
+
out.append((text, (xe[c] + xe[c + 1]) / 2, (ye[r] + ye[r + 1]) / 2, ink_for(col),
|
|
294
|
+
"center", "center"))
|
|
295
|
+
return out
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def memo_grid(ctx: Any, key: Any, make: Any) -> Any:
|
|
299
|
+
from ._util import memo
|
|
300
|
+
return memo(ctx, (key, "grid"), make)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@role("heatmap.cells")
|
|
304
|
+
def _cells_paint(s: Heatmap, ctx: Any, glyph: bool, grid: Any = None,
|
|
305
|
+
cmap: Any = None) -> dict[str, Any]:
|
|
306
|
+
"""The cells of ``grid(ctx)``: through the color map ``cmap``, or colors."""
|
|
307
|
+
g = grid(ctx)
|
|
308
|
+
opacity = s.resolve("opacity")
|
|
309
|
+
mode = s.resolve("kind")
|
|
310
|
+
p: dict[str, Any] = {"x_edges": g["xe"], "y_edges": g["ye"],
|
|
311
|
+
"even": _even(g["xe"]) and _even(g["ye"]), "mode": mode,
|
|
312
|
+
"opacity": 1.0 if opacity is None else opacity}
|
|
313
|
+
if cmap is not None:
|
|
314
|
+
flat = array.array("d")
|
|
315
|
+
for row in g["rows"]:
|
|
316
|
+
flat.extend(row)
|
|
317
|
+
p["fill"], p["palette"], p["bounds"] = cmap.cells(flat, g["flo"], g["fhi"], mode)
|
|
318
|
+
nan = ctx.color(s.resolve("nan_color"))
|
|
319
|
+
p["bad"] = nan if nan is not None else cmap.bad
|
|
320
|
+
else:
|
|
321
|
+
p["fill"] = [c for row in g["rows"] for c in row]
|
|
322
|
+
return p
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Hexagonal 2-D histograms (stat ``hexbin``): counts of points in hexagons
|
|
2
|
+
(``Polygons``, one hexagon repeated), colored by a map."""
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import vec_io
|
|
8
|
+
|
|
9
|
+
from .._methods import HexbinMixin
|
|
10
|
+
from .. import groups
|
|
11
|
+
from .. import columns, kernels
|
|
12
|
+
from ..color.values import with_alpha
|
|
13
|
+
from ..layers import stats
|
|
14
|
+
from ..layers.base import Series, common
|
|
15
|
+
from ..layers.core import Call, Layer, Table
|
|
16
|
+
from ..layers.grammar import entry
|
|
17
|
+
from ..layers.paint import Paint, role
|
|
18
|
+
from ..layers.geoms import GEOMS
|
|
19
|
+
from ..mapped import color_axis_of, colors_of
|
|
20
|
+
from ..settings import Setting, number, one_of, table
|
|
21
|
+
from ..layers.pipeline import render_of
|
|
22
|
+
from ..transform import settings_both as coord_settings
|
|
23
|
+
from ._util import columns_meta
|
|
24
|
+
|
|
25
|
+
__all__ = ["Hexbin"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_S3 = math.sqrt(3.0)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _frame_of(ctx: Any) -> tuple:
|
|
32
|
+
"""The frame a lattice is laid out in: the axes' mappers relative to their top-left
|
|
33
|
+
corner, and their size."""
|
|
34
|
+
x0, y0, x1, y1 = ctx.rect
|
|
35
|
+
xm, ym = ctx.xmap, ctx.ymap
|
|
36
|
+
return (xm.function, xm.a, xm.b - x0, ym.function, ym.a, ym.b - y0, x1 - x0, y1 - y0)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _same(f: tuple, g: tuple) -> bool:
|
|
40
|
+
return f[0] == g[0] and f[3] == g[3] and all(
|
|
41
|
+
abs(a - b) <= 1e-6 * max(1.0, abs(a), abs(b))
|
|
42
|
+
for a, b in zip(f[1:3] + f[4:], g[1:3] + g[4:]))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Hexbin(HexbinMixin, Series):
|
|
46
|
+
"""A hexagonal 2-D histogram of points ``(x, y)``: ``bins`` regular hexagons across
|
|
47
|
+
the axes width. The lattice is laid out in page units for the axes the layout placed
|
|
48
|
+
(its domain is the data's, its bins the axes'), so the hexagons are regular and tile
|
|
49
|
+
exactly whatever the axes' aspect.
|
|
50
|
+
|
|
51
|
+
The counts depend on the axes, and the color axis on the counts: the layout counts again
|
|
52
|
+
when a round places the axes differently (`layout <docs/api.md#layout>`_). Points outside the
|
|
53
|
+
axes are not counted. Colored by ``color_map``; a colorbar source.
|
|
54
|
+
|
|
55
|
+
Example::
|
|
56
|
+
|
|
57
|
+
rng = random.Random(1)
|
|
58
|
+
x = [rng.gauss(0, 1) for _ in range(1000)]
|
|
59
|
+
p = plt.Plot(width=240, height=160)
|
|
60
|
+
p.hexbin(x, [v + rng.gauss(0, 0.7) for v in x], bins=16)
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
SETTINGS = table(
|
|
64
|
+
**coord_settings(),
|
|
65
|
+
bins=Setting(number(lo=1, integer=True), doc="Hexagons across the axes width."),
|
|
66
|
+
weights=Setting(lambda v: columns.numbers(v, "weights"), doc="A weight per point (sums)."),
|
|
67
|
+
stat=Setting(one_of("count", "probability", "percent"),
|
|
68
|
+
doc="`count`, `probability` or `percent`."),
|
|
69
|
+
min_count=Setting(number(lo=0), doc="Hexagons below this count are not drawn."),
|
|
70
|
+
**common("color_map"),
|
|
71
|
+
**groups.Stroke.embed("edge_",
|
|
72
|
+
only=("color", "line_width"),
|
|
73
|
+
docs={"color": "Hexagon outline color; default none.",
|
|
74
|
+
"line_width": "Outline width."}),
|
|
75
|
+
**common("name", "opacity", "z", "rasterize", "simplify", "font_size", "font_family",
|
|
76
|
+
"text_color"),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
DATA = {"x": "numbers", "y": "numbers"}
|
|
80
|
+
|
|
81
|
+
def __init__(self, x: Any, y: Any, plot: Any = None, **settings: Any) -> None:
|
|
82
|
+
super().__init__(plot=plot, **settings)
|
|
83
|
+
|
|
84
|
+
def _bar_source(self) -> Any:
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def _nominal_frame(self) -> tuple:
|
|
88
|
+
"""Outside a render: the lattice over the data range and the figure's nominal axes
|
|
89
|
+
size (``width`` × ``height``)."""
|
|
90
|
+
fig = self.plot.figure
|
|
91
|
+
w, h = fig.reference_size()
|
|
92
|
+
out: list[Any] = []
|
|
93
|
+
for axis, values, (p0, p1) in ((self.plot.x_axis, self.cols["x"], (0.0, w)),
|
|
94
|
+
(self.plot.y_axis, self.cols["y"], (h, 0.0))):
|
|
95
|
+
sc = stats.axis_scale(axis)
|
|
96
|
+
n, lo, hi = kernels.extent(values, sc.function)
|
|
97
|
+
if not n:
|
|
98
|
+
lo, hi = 0.0, 1.0
|
|
99
|
+
if hi == lo:
|
|
100
|
+
lo, hi = lo - 0.5, hi + 0.5
|
|
101
|
+
name = "auto_padding" if axis.get("padding") == "auto" else "padding"
|
|
102
|
+
px, frac = axis.resolve_linear(name) # px + fraction of the range
|
|
103
|
+
span = abs(p1 - p0)
|
|
104
|
+
d = (hi - lo) * frac
|
|
105
|
+
if px and span > 2 * px:
|
|
106
|
+
d = (hi - lo + 2 * d) * px / (span - 2 * px) + d
|
|
107
|
+
lo, hi = lo - d, hi + d
|
|
108
|
+
a = (p1 - p0) / (hi - lo)
|
|
109
|
+
out += [sc.function, a, p0 - a * lo]
|
|
110
|
+
return (*out, w, h)
|
|
111
|
+
|
|
112
|
+
def _frame(self) -> tuple | None:
|
|
113
|
+
"""The frame of the axes the layout placed (``None`` in a render before the first
|
|
114
|
+
placement), else, outside a render, the nominal one."""
|
|
115
|
+
r = render_of(self.plot)
|
|
116
|
+
if r is None:
|
|
117
|
+
return self._nominal_frame()
|
|
118
|
+
ctx = r.placed.get(id(self.plot))
|
|
119
|
+
return None if ctx is None else _frame_of(ctx)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def stats(self) -> dict[str, Any]:
|
|
123
|
+
"""``{"x", "y": hexagon centres (page units, relative to the axes' top-left corner),
|
|
124
|
+
"values", "dx": hexagon width (px)}`` for the placed axes (see :meth:`_frame`)."""
|
|
125
|
+
frame = self._frame()
|
|
126
|
+
if frame is None:
|
|
127
|
+
raise ValueError(f"{self!r}: the hexagons are counted once the axes are placed")
|
|
128
|
+
return stats.compute(self, "hexbin", (), frame, kind=None)
|
|
129
|
+
|
|
130
|
+
def page_stats(self, ctx: Any) -> dict[str, Any]:
|
|
131
|
+
"""The stats for the axes of ``ctx``: those of the placed axes, or (placed
|
|
132
|
+
differently) counted again; the colors keep the color axis trained before."""
|
|
133
|
+
f = _frame_of(ctx)
|
|
134
|
+
placed = self._frame()
|
|
135
|
+
if placed is not None and _same(f, placed):
|
|
136
|
+
return self.stats
|
|
137
|
+
return stats.compute(self, "hexbin", (), f, kind=None)
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def hexagon(dx: float) -> list[tuple[float, float]]:
|
|
141
|
+
"""Vertices of the regular hexagon (pointy top) of width ``dx`` centred on 0."""
|
|
142
|
+
r = dx / _S3
|
|
143
|
+
return [(0.5 * dx, -0.5 * r), (0.5 * dx, 0.5 * r), (0.0, r), (-0.5 * dx, 0.5 * r),
|
|
144
|
+
(-0.5 * dx, -0.5 * r), (0.0, -r)]
|
|
145
|
+
|
|
146
|
+
def layers(self) -> Call:
|
|
147
|
+
call = Call(self)
|
|
148
|
+
axis = color_axis_of(self)
|
|
149
|
+
|
|
150
|
+
def page(ctx: Any) -> dict[str, Any]:
|
|
151
|
+
return self.page_stats(ctx)
|
|
152
|
+
|
|
153
|
+
t = Table()
|
|
154
|
+
t.lazy["x"] = lambda ctx: kernels.scaled(page(ctx)["x"], 1.0, ctx.rect[0])
|
|
155
|
+
t.lazy["y"] = lambda ctx: kernels.scaled(page(ctx)["y"], 1.0, ctx.rect[1])
|
|
156
|
+
t.lazy["size"] = lambda ctx: page(ctx)["dx"] * 1.002
|
|
157
|
+
t.lazy["half"] = lambda ctx: (page(ctx)["dx"] / 2, page(ctx)["dx"] / _S3)
|
|
158
|
+
|
|
159
|
+
def shape(ctx: Any) -> Any:
|
|
160
|
+
dx = page(ctx)["dx"]
|
|
161
|
+
path = vec_io.Path()
|
|
162
|
+
pts = [(x / dx, y / dx) for x, y in self.hexagon(dx)]
|
|
163
|
+
path.move_to(*pts[0])
|
|
164
|
+
for p in pts[1:]:
|
|
165
|
+
path.line_to(*p)
|
|
166
|
+
path.close()
|
|
167
|
+
return path
|
|
168
|
+
t.lazy["shape"] = shape
|
|
169
|
+
|
|
170
|
+
layer = Layer(GEOMS["polygons"], t, paint=Paint(self, "hexbin.cells"), stat="hexbin",
|
|
171
|
+
glyph="hexagon")
|
|
172
|
+
call.layers.append(layer)
|
|
173
|
+
name = self.resolve("name")
|
|
174
|
+
if name is not None:
|
|
175
|
+
call.entries.append(entry(self, name, [layer], fill="symbol", marker=layer.paint))
|
|
176
|
+
|
|
177
|
+
def counts() -> Any: # trained once the axes are placed
|
|
178
|
+
return None if self._frame() is None else self.stats["values"]
|
|
179
|
+
call.color_sources = [(axis, counts)]
|
|
180
|
+
call.extra = {"x": self.cols["x"], "y": self.cols["y"]}
|
|
181
|
+
call.resolution = _frame_of
|
|
182
|
+
columns_meta(call, {})
|
|
183
|
+
return call
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@role("hexbin.cells")
|
|
187
|
+
def _cells_paint(s: Hexbin, ctx: Any, glyph: bool) -> dict[str, Any]:
|
|
188
|
+
"""The hexagons in their counts' colors (the legend's: the map at 0.6)."""
|
|
189
|
+
axis = color_axis_of(s)
|
|
190
|
+
a = s.resolve("opacity")
|
|
191
|
+
if glyph:
|
|
192
|
+
return {"fill": with_alpha(axis.color_map()(0.6), a)}
|
|
193
|
+
lc = ctx.color(s.resolve("edge_color"))
|
|
194
|
+
lw = s.resolve("edge_width")
|
|
195
|
+
return {"fill": colors_of(axis, s.page_stats(ctx)["values"]),
|
|
196
|
+
"stroke": lc if lw and lc is not None else None,
|
|
197
|
+
"line_width": lw or None, "opacity": 1.0 if a is None else a,
|
|
198
|
+
"simplify": s.resolve("simplify")}
|