fluxplot 0.1.0__py3-none-any.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.
- fluxplot/__init__.py +115 -0
- fluxplot/_fieldmap.py +97 -0
- fluxplot/_mesh_reduce.py +54 -0
- fluxplot/_scene3d_size.py +95 -0
- fluxplot/_viewer/THIRD-PARTY.txt +23 -0
- fluxplot/_viewer/flux-model3d-viewer.min.js +4221 -0
- fluxplot/_viewer/stamp.json +4 -0
- fluxplot/api.py +1196 -0
- fluxplot/autotag.py +164 -0
- fluxplot/base.mplstyle +0 -0
- fluxplot/brackets.py +242 -0
- fluxplot/canonical_json.py +23 -0
- fluxplot/capture.py +150 -0
- fluxplot/colorcheck.py +285 -0
- fluxplot/colors.py +727 -0
- fluxplot/colorscale.py +477 -0
- fluxplot/data.py +178 -0
- fluxplot/definitions/colormaps.json +1639 -0
- fluxplot/definitions/flexoki.tokens.json +2571 -0
- fluxplot/definitions/palettes.json +2547 -0
- fluxplot/descriptors.py +87 -0
- fluxplot/fields.py +611 -0
- fluxplot/fits.py +240 -0
- fluxplot/glb.py +84 -0
- fluxplot/ids.py +173 -0
- fluxplot/images.py +362 -0
- fluxplot/integrity.py +27 -0
- fluxplot/manifest.py +788 -0
- fluxplot/mesh3d.py +376 -0
- fluxplot/panels.py +284 -0
- fluxplot/postprocess.py +638 -0
- fluxplot/presets.py +66 -0
- fluxplot/provenance.py +177 -0
- fluxplot/raster.py +295 -0
- fluxplot/recipe.py +178 -0
- fluxplot/render.py +66 -0
- fluxplot/roles.py +147 -0
- fluxplot/scene3d.py +386 -0
- fluxplot/scene3d_manifest.py +112 -0
- fluxplot/scene3d_viewer.py +633 -0
- fluxplot/schemas/.gitkeep +0 -0
- fluxplot/schemas/manifest.schema.json +2479 -0
- fluxplot/schemas/recipe.schema.json +179 -0
- fluxplot/schemas/scene3d.schema.json +461 -0
- fluxplot/seaborn_adapters.py +323 -0
- fluxplot/signature_fluxplots/__init__.py +18 -0
- fluxplot/signature_fluxplots/_colour.py +412 -0
- fluxplot/signature_fluxplots/fluxbox.py +433 -0
- fluxplot/signature_fluxplots/glowbar.py +769 -0
- fluxplot/signature_fluxplots/hexmatrix.py +927 -0
- fluxplot/stats/__init__.py +63 -0
- fluxplot/stats/_common.py +196 -0
- fluxplot/stats/multi_group.py +443 -0
- fluxplot/stats/paired.py +209 -0
- fluxplot/stats/two_group.py +149 -0
- fluxplot/style.py +469 -0
- fluxplot/surface.py +487 -0
- fluxplot/surface3d.py +197 -0
- fluxplot/tagger.py +561 -0
- fluxplot/version.py +19 -0
- fluxplot-0.1.0.dist-info/METADATA +1199 -0
- fluxplot-0.1.0.dist-info/RECORD +65 -0
- fluxplot-0.1.0.dist-info/WHEEL +4 -0
- fluxplot-0.1.0.dist-info/licenses/LICENSE +21 -0
- fluxplot-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +472 -0
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
"""``fp.hexmatrix`` — hexagonal binning and hex-lattice maps, every hexagon a named part.
|
|
2
|
+
|
|
3
|
+
A hexmatrix tiles the plane with regular hexagons and colours each one by a value. It covers three
|
|
4
|
+
jobs with one mark:
|
|
5
|
+
|
|
6
|
+
* **density** of a point cloud (``x``, ``y``) — how many observations fall in each hexagon, or a
|
|
7
|
+
density / probability / percent of them, optionally weighted (the classic hexbin);
|
|
8
|
+
* a **2D gradient** of a third variable (``C``) — the mean / median / sum / std / any reduction of
|
|
9
|
+
``C`` over the observations in each hexagon;
|
|
10
|
+
* a **hex matrix** (``matrix=``) — a 2D array drawn on an offset hex lattice (a SOM component plane,
|
|
11
|
+
a hexagonal detector, a tiled spatial map), one hexagon per entry.
|
|
12
|
+
|
|
13
|
+
Everything shares one lattice: hexagons are addressed by ``(row, col)`` — ``row`` counts up the y
|
|
14
|
+
axis, ``col`` along x — so a hexagon has the same name in every plot made with the same extent and
|
|
15
|
+
grid, whatever the data. Hexagons are regular *on the page*: with ``aspect="auto"`` the axes' box
|
|
16
|
+
aspect is locked so a later layout pass cannot squash them; ``aspect="equal"`` bins in true data
|
|
17
|
+
units (spatial coordinates). Log axes bin in log space (``xscale="log"``), exactly as they display.
|
|
18
|
+
|
|
19
|
+
======================= =============================================== ===========
|
|
20
|
+
part default id role
|
|
21
|
+
======================= =============================================== ===========
|
|
22
|
+
all the hexagons ``<series>.hexes`` ``x-hexbin``
|
|
23
|
+
one hexagon ``<series>.hex.<row>.<col>`` ``x-hex``
|
|
24
|
+
sparse / raw points ``<series>-points.points``, ``….point.<k>`` ``point``
|
|
25
|
+
identity line ``reference-line.identity`` ``reference-line``
|
|
26
|
+
marginal histograms ``<series>-x.bar.<k>`` / ``<series>-y.bar.<k>`` ``bar``
|
|
27
|
+
colour key ``colorbar.color`` ``colorbar``
|
|
28
|
+
======================= =============================================== ===========
|
|
29
|
+
|
|
30
|
+
Each hexagon also carries ``data-row``, ``data-column``, ``data-x``, ``data-y`` (its centre, in data
|
|
31
|
+
units), ``data-count`` and ``data-value`` in the SVG. The series' ``hexmatrix`` payload in the
|
|
32
|
+
manifest records the lattice (orientation, radius, extent, scales), the statistic and every bin drawn;
|
|
33
|
+
its ``field`` payload records the colormap and normalisation, and — like ``fp.heatmap`` — the
|
|
34
|
+
colormap and colour limits are recipe controls, so Flux's *Color scales* editor can change them and
|
|
35
|
+
regenerate.
|
|
36
|
+
|
|
37
|
+
Example
|
|
38
|
+
-------
|
|
39
|
+
>>> import fluxplot as fp, matplotlib.pyplot as plt
|
|
40
|
+
>>> fig, ax = plt.subplots(figsize=(3, 3))
|
|
41
|
+
>>> hm = fp.hexmatrix(df, x="wake_rate", y="nrem_rate", ax=ax, xscale="log", yscale="log",
|
|
42
|
+
... norm="log", identity_line=True, colorbar_label="Synapses per hexbin")
|
|
43
|
+
>>> hm.bins["count"].max()
|
|
44
|
+
41
|
|
45
|
+
>>> fp.save(fig, "plots/rates.svg")
|
|
46
|
+
"""
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import colorsys
|
|
50
|
+
import warnings
|
|
51
|
+
from dataclasses import dataclass, field
|
|
52
|
+
from numbers import Real
|
|
53
|
+
from typing import Any, Callable, Optional, Sequence, Union
|
|
54
|
+
|
|
55
|
+
import numpy as np
|
|
56
|
+
|
|
57
|
+
from .. import ids as _ids
|
|
58
|
+
from .. import tagger as _tagger
|
|
59
|
+
from ..descriptors import Mark
|
|
60
|
+
from .glowbar import _column, _floats, _hex, _plain
|
|
61
|
+
|
|
62
|
+
__all__ = ["hexmatrix", "HexMatrixResult"]
|
|
63
|
+
|
|
64
|
+
SQRT3 = float(np.sqrt(3.0))
|
|
65
|
+
#: prefix of the single-colour ramps built from ``color=`` — recorded as the colormap's name so a
|
|
66
|
+
#: Flux regeneration (which replays the recorded name) rebuilds exactly the same map
|
|
67
|
+
MONO_PREFIX = "hexmatrix.mono:"
|
|
68
|
+
#: the same ramp turned for a dark ground: deep (near the ground) → the colour → pale
|
|
69
|
+
MONO_DARK_PREFIX = "hexmatrix.mono-dark:"
|
|
70
|
+
_STATS = ("count", "density", "probability", "percent")
|
|
71
|
+
_NORMS = ("linear", "log", "sqrt")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# the lattice
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
def _nearest(a, b, R):
|
|
78
|
+
"""Nearest centre of a pointy-top lattice of circumradius ``R`` → ``(row along b, col along a)``.
|
|
79
|
+
|
|
80
|
+
The lattice is two offset rectangular lattices (even and odd rows); a point belongs to whichever
|
|
81
|
+
centre is nearer, which is exactly the hexagon (the lattice's Voronoi cell) containing it. Ties go
|
|
82
|
+
to the even row, deterministically.
|
|
83
|
+
"""
|
|
84
|
+
dx, dy = SQRT3 * R, 1.5 * R
|
|
85
|
+
i1, j1 = np.round(a / dx), np.round(b / (2 * dy))
|
|
86
|
+
i2, j2 = np.round((a - dx / 2) / dx), np.round((b - dy) / (2 * dy))
|
|
87
|
+
d1 = (a - i1 * dx) ** 2 + (b - 2 * j1 * dy) ** 2
|
|
88
|
+
d2 = (a - i2 * dx - dx / 2) ** 2 + (b - (2 * j2 + 1) * dy) ** 2
|
|
89
|
+
odd = d2 < d1
|
|
90
|
+
return np.where(odd, 2 * j2 + 1, 2 * j1).astype(int), np.where(odd, i2, i1).astype(int)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _centre(row, col, R):
|
|
94
|
+
"""Centre of pointy-top lattice cell ``(row, col)`` → ``(a, b)``."""
|
|
95
|
+
row, col = np.asarray(row), np.asarray(col)
|
|
96
|
+
return col * SQRT3 * R + (row % 2) * SQRT3 * R / 2, row * 1.5 * R
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _Lattice:
|
|
100
|
+
"""Maps data ↔ the unit plane the hexagons are regular in, for either orientation.
|
|
101
|
+
|
|
102
|
+
``u`` runs along x, ``v`` along y. A flat-top lattice is the pointy-top one with ``u``/``v``
|
|
103
|
+
swapped, so ``row`` (the y index) and ``col`` (the x index) keep their meaning in both.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(self, *, R, orientation, x0, sx, y0, sy, xscale, yscale):
|
|
107
|
+
self.R, self.orientation = R, orientation
|
|
108
|
+
self.x0, self.sx, self.y0, self.sy = x0, sx, y0, sy # u = (X - x0) / sx, on scaled X
|
|
109
|
+
self.xscale, self.yscale = xscale, yscale
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def fwd(v, scale):
|
|
113
|
+
return np.log10(v) if scale == "log" else np.asarray(v, dtype=float)
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def inv(v, scale):
|
|
117
|
+
return 10.0 ** v if scale == "log" else v
|
|
118
|
+
|
|
119
|
+
def to_unit(self, x, y):
|
|
120
|
+
return ((self.fwd(x, self.xscale) - self.x0) / self.sx,
|
|
121
|
+
(self.fwd(y, self.yscale) - self.y0) / self.sy)
|
|
122
|
+
|
|
123
|
+
def to_data(self, u, v):
|
|
124
|
+
return (self.inv(np.asarray(u) * self.sx + self.x0, self.xscale),
|
|
125
|
+
self.inv(np.asarray(v) * self.sy + self.y0, self.yscale))
|
|
126
|
+
|
|
127
|
+
def index(self, u, v):
|
|
128
|
+
if self.orientation == "pointy":
|
|
129
|
+
return _nearest(u, v, self.R)
|
|
130
|
+
c, r = _nearest(v, u, self.R)
|
|
131
|
+
return r, c
|
|
132
|
+
|
|
133
|
+
def centre(self, row, col):
|
|
134
|
+
if self.orientation == "pointy":
|
|
135
|
+
return _centre(row, col, self.R)
|
|
136
|
+
b, a = _centre(col, row, self.R)
|
|
137
|
+
return a, b
|
|
138
|
+
|
|
139
|
+
def polygons(self, row, col, shrink=1.0):
|
|
140
|
+
"""Hexagon vertices in data coordinates, one ``(6, 2)`` array per cell."""
|
|
141
|
+
u, v = self.centre(row, col)
|
|
142
|
+
start = 90.0 if self.orientation == "pointy" else 0.0
|
|
143
|
+
ang = np.deg2rad(start + 60.0 * np.arange(6))
|
|
144
|
+
du, dv = self.R * shrink * np.cos(ang), self.R * shrink * np.sin(ang)
|
|
145
|
+
x, y = self.to_data(u[:, None] + du[None, :], v[:, None] + dv[None, :])
|
|
146
|
+
return np.stack([x, y], axis=-1)
|
|
147
|
+
|
|
148
|
+
def area(self):
|
|
149
|
+
"""Area of one hexagon in (scaled) data units²."""
|
|
150
|
+
return 1.5 * SQRT3 * self.R ** 2 * self.sx * self.sy
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# colour
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
def _mono_cmap(color, dark=False):
|
|
157
|
+
"""A single-hue ramp at the colour's own hue and saturation: pale → ``color`` → deep (seaborn's
|
|
158
|
+
jointplot ramp, lightness 95 % → 12 %) on a light ground; on a dark ground (``dark=True``) the
|
|
159
|
+
ramp is turned — deep (near the ground) → pale — so the fullest hexagons are the ones that
|
|
160
|
+
stand out."""
|
|
161
|
+
from matplotlib.colors import LinearSegmentedColormap, to_rgb
|
|
162
|
+
h, _l, s = colorsys.rgb_to_hls(*to_rgb(color))
|
|
163
|
+
lums = np.linspace(0.22, 0.95, 12) if dark else np.linspace(0.95, 0.12, 12)
|
|
164
|
+
ramp = [colorsys.hls_to_rgb(h, lum, s) for lum in lums]
|
|
165
|
+
return LinearSegmentedColormap.from_list((MONO_DARK_PREFIX if dark else MONO_PREFIX) + _hex(color), ramp)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _resolve_cmap(spec):
|
|
169
|
+
from .._fieldmap import resolve_colormap
|
|
170
|
+
if isinstance(spec, str) and spec.startswith(MONO_DARK_PREFIX):
|
|
171
|
+
return _mono_cmap(spec[len(MONO_DARK_PREFIX):], dark=True)
|
|
172
|
+
if isinstance(spec, str) and spec.startswith(MONO_PREFIX):
|
|
173
|
+
return _mono_cmap(spec[len(MONO_PREFIX):])
|
|
174
|
+
return resolve_colormap(spec)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _ground_is_dark(ax) -> bool:
|
|
178
|
+
from ._colour import is_dark
|
|
179
|
+
for c in (ax.get_facecolor(), ax.figure.get_facecolor()):
|
|
180
|
+
if c[3] > 0:
|
|
181
|
+
return is_dark(c)
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _auto_limits(vals, norm, robust, center):
|
|
186
|
+
"""Colour limits from the drawn values: min/max (positive only for log), robust percentiles,
|
|
187
|
+
and symmetric about ``center`` when one is given."""
|
|
188
|
+
v = vals[np.isfinite(vals)]
|
|
189
|
+
if norm == "log":
|
|
190
|
+
v = v[v > 0]
|
|
191
|
+
if not v.size:
|
|
192
|
+
return (1.0, 10.0) if norm == "log" else (0.0, 1.0)
|
|
193
|
+
if robust:
|
|
194
|
+
lo_p, hi_p = (2.0, 98.0) if robust is True else robust
|
|
195
|
+
lo, hi = (float(q) for q in np.percentile(v, [lo_p, hi_p]))
|
|
196
|
+
else:
|
|
197
|
+
lo, hi = float(v.min()), float(v.max())
|
|
198
|
+
if center is not None:
|
|
199
|
+
half = max(abs(lo - center), abs(hi - center)) or 1.0
|
|
200
|
+
lo, hi = center - half, center + half
|
|
201
|
+
if lo == hi:
|
|
202
|
+
lo, hi = (lo / 10.0, hi * 10.0) if norm == "log" else (lo - 0.5, hi + 0.5)
|
|
203
|
+
return lo, hi
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _make_norm(norm, center, vmin, vmax, gamma):
|
|
207
|
+
from matplotlib import colors as mcolors
|
|
208
|
+
if center is not None and norm != "linear":
|
|
209
|
+
# a TwoSlopeNorm IS the scale; it cannot also be log / sqrt / a caller's Normalize
|
|
210
|
+
raise ValueError("hexmatrix: center= needs a linear norm")
|
|
211
|
+
if isinstance(norm, mcolors.Normalize):
|
|
212
|
+
return norm
|
|
213
|
+
if center is not None:
|
|
214
|
+
return mcolors.TwoSlopeNorm(vcenter=center, vmin=vmin, vmax=vmax)
|
|
215
|
+
if norm == "log":
|
|
216
|
+
return mcolors.LogNorm(vmin=vmin, vmax=vmax)
|
|
217
|
+
if norm == "sqrt":
|
|
218
|
+
return mcolors.PowerNorm(gamma=gamma, vmin=vmin, vmax=vmax)
|
|
219
|
+
return mcolors.Normalize(vmin=vmin, vmax=vmax)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _plain_log_ticks(axis, lo, hi, subs=(1.0,)):
|
|
223
|
+
"""Label a log axis with plain numbers (0.01, 0.1, 1, 10 — not 10⁻²) while it spans no more
|
|
224
|
+
than ~10⁻⁴…10⁵, where plain numbers stay short."""
|
|
225
|
+
from matplotlib.ticker import FuncFormatter, LogLocator, NullFormatter
|
|
226
|
+
if not (lo > 0 and hi > 0 and lo >= 1e-4 and hi <= 1e5):
|
|
227
|
+
return
|
|
228
|
+
axis.set_major_locator(LogLocator(base=10, subs=subs))
|
|
229
|
+
axis.set_major_formatter(FuncFormatter(lambda v, _pos: f"{v:g}"))
|
|
230
|
+
axis.set_minor_formatter(NullFormatter())
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
# aggregation
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
def _reducer(reduce):
|
|
237
|
+
if callable(reduce):
|
|
238
|
+
return reduce, getattr(reduce, "__name__", "custom")
|
|
239
|
+
table = {
|
|
240
|
+
"mean": np.mean, "median": np.median, "sum": np.sum, "min": np.min, "max": np.max,
|
|
241
|
+
"std": lambda v: float(np.std(v, ddof=1)) if v.size > 1 else float("nan"),
|
|
242
|
+
"count": lambda v: float(v.size),
|
|
243
|
+
}
|
|
244
|
+
if reduce not in table:
|
|
245
|
+
raise ValueError(f"hexmatrix: reduce must be one of {sorted(table)} or a callable; got {reduce!r}")
|
|
246
|
+
return table[reduce], reduce
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _bin_points(lat, u, v, C, weights, reduce):
|
|
250
|
+
"""Group points by hexagon → ``(rows, cols, counts, weighted, value, inverse)``; bins in
|
|
251
|
+
``(row, col)`` order."""
|
|
252
|
+
rows, cols = lat.index(u, v)
|
|
253
|
+
keys, inv = np.unique(np.stack([rows, cols], axis=1), axis=0, return_inverse=True)
|
|
254
|
+
inv = inv.reshape(-1)
|
|
255
|
+
counts = np.bincount(inv, minlength=len(keys)).astype(float)
|
|
256
|
+
weighted = counts if weights is None else np.bincount(inv, weights=weights, minlength=len(keys))
|
|
257
|
+
value = None
|
|
258
|
+
if C is not None:
|
|
259
|
+
fn, name = _reducer(reduce)
|
|
260
|
+
if name in ("mean", "sum") and weights is None:
|
|
261
|
+
s = np.bincount(inv, weights=C, minlength=len(keys))
|
|
262
|
+
value = s / counts if name == "mean" else s
|
|
263
|
+
else:
|
|
264
|
+
order = np.argsort(inv, kind="stable")
|
|
265
|
+
groups = np.split(C[order], np.cumsum(counts.astype(int))[:-1])
|
|
266
|
+
value = np.array([float(fn(g)) for g in groups])
|
|
267
|
+
return keys[:, 0], keys[:, 1], counts, weighted, value, inv
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _lattice_cells(lat, u_lo, u_hi, v_lo, v_hi):
|
|
271
|
+
"""Every lattice cell whose centre lies inside the unit-space box (for ``mincnt=0``)."""
|
|
272
|
+
R = lat.R
|
|
273
|
+
span = int(np.ceil(max(u_hi - u_lo, v_hi - v_lo) / R)) + 4
|
|
274
|
+
r, c = np.meshgrid(np.arange(-2, span), np.arange(-2, span), indexing="ij")
|
|
275
|
+
r, c = r.ravel(), c.ravel()
|
|
276
|
+
u, v = lat.centre(r, c)
|
|
277
|
+
eps = 1e-9 * R
|
|
278
|
+
keep = (u >= u_lo - eps) & (u <= u_hi + eps) & (v >= v_lo - eps) & (v <= v_hi + eps)
|
|
279
|
+
return r[keep], c[keep]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# ---------------------------------------------------------------------------
|
|
283
|
+
# result
|
|
284
|
+
# ---------------------------------------------------------------------------
|
|
285
|
+
@dataclass
|
|
286
|
+
class HexMatrixResult:
|
|
287
|
+
"""What :func:`hexmatrix` drew — the axes, the hexagons and everything needed to reuse them."""
|
|
288
|
+
|
|
289
|
+
ax: Any
|
|
290
|
+
#: the hexagons (a matplotlib PolyCollection; also the colorbar mappable)
|
|
291
|
+
hexes: Any
|
|
292
|
+
#: one entry per drawn hexagon, in ``(row, col)`` order: ``row``, ``col``, ``x``, ``y`` (centre,
|
|
293
|
+
#: data units), ``count`` (observations; ``None`` for a matrix) and ``value`` (what is coloured)
|
|
294
|
+
bins: dict
|
|
295
|
+
#: the colormap and normalisation actually used (after any Flux recipe override)
|
|
296
|
+
cmap: Any
|
|
297
|
+
norm: Any
|
|
298
|
+
#: the series name — the root of every part id
|
|
299
|
+
series: str
|
|
300
|
+
#: the recipe key under which Flux edits the colormap and limits
|
|
301
|
+
control_key: str
|
|
302
|
+
colorbar: Any = None
|
|
303
|
+
#: ``{"x": ax_top, "y": ax_right}`` when marginals were drawn
|
|
304
|
+
marginal_axes: dict = field(default_factory=dict)
|
|
305
|
+
#: the raw points drawn (``show_points`` / ``sparse``), a PathCollection or ``None``
|
|
306
|
+
points: Any = None
|
|
307
|
+
#: every part → its matplotlib artist(s)
|
|
308
|
+
artists: dict = field(default_factory=dict)
|
|
309
|
+
|
|
310
|
+
def hex_id(self, row, col) -> str:
|
|
311
|
+
"""The SVG id of hexagon ``(row, col)`` (before any panel prefix)."""
|
|
312
|
+
return f"{_ids.series_root(self.series)}.hex.{int(row)}.{int(col)}"
|
|
313
|
+
|
|
314
|
+
def lookup(self, x, y):
|
|
315
|
+
"""``(row, col)`` of the hexagon containing data point ``(x, y)``."""
|
|
316
|
+
lat = self.artists["lattice"]
|
|
317
|
+
u, v = lat.to_unit(np.atleast_1d(x), np.atleast_1d(y))
|
|
318
|
+
r, c = lat.index(u, v)
|
|
319
|
+
return int(r[0]), int(c[0])
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
# the plot
|
|
324
|
+
# ---------------------------------------------------------------------------
|
|
325
|
+
def hexmatrix(
|
|
326
|
+
data=None,
|
|
327
|
+
*,
|
|
328
|
+
x=None,
|
|
329
|
+
y=None,
|
|
330
|
+
C=None,
|
|
331
|
+
matrix=None,
|
|
332
|
+
ax=None,
|
|
333
|
+
# the lattice
|
|
334
|
+
gridsize: int = 30,
|
|
335
|
+
binwidth: Optional[float] = None,
|
|
336
|
+
extent: Optional[Sequence[float]] = None,
|
|
337
|
+
orientation: str = "pointy",
|
|
338
|
+
aspect: Union[str, float] = "auto",
|
|
339
|
+
xscale: Optional[str] = None,
|
|
340
|
+
yscale: Optional[str] = None,
|
|
341
|
+
origin: str = "upper",
|
|
342
|
+
# what is coloured
|
|
343
|
+
stat: str = "count",
|
|
344
|
+
reduce: Union[str, Callable] = "mean",
|
|
345
|
+
weights=None,
|
|
346
|
+
mincnt: int = 1,
|
|
347
|
+
# the colour scale
|
|
348
|
+
cmap=None,
|
|
349
|
+
color=None,
|
|
350
|
+
norm: Any = "linear",
|
|
351
|
+
vmin: Optional[float] = None,
|
|
352
|
+
vmax: Optional[float] = None,
|
|
353
|
+
robust: Union[bool, Sequence[float]] = False,
|
|
354
|
+
center: Optional[float] = None,
|
|
355
|
+
gamma: float = 0.5,
|
|
356
|
+
# the hexagons
|
|
357
|
+
gap: float = 0.0,
|
|
358
|
+
edgecolor="face",
|
|
359
|
+
linewidth: Optional[float] = None,
|
|
360
|
+
alpha: Optional[float] = None,
|
|
361
|
+
# points
|
|
362
|
+
sparse: Optional[int] = None,
|
|
363
|
+
show_points: bool = False,
|
|
364
|
+
point_size: float = 4.0,
|
|
365
|
+
point_color=None,
|
|
366
|
+
# furniture
|
|
367
|
+
colorbar: bool = True,
|
|
368
|
+
colorbar_label: Optional[str] = None,
|
|
369
|
+
colorbar_size: float = 0.05,
|
|
370
|
+
colorbar_pad: float = 0.04,
|
|
371
|
+
marginals: Union[bool, str] = False,
|
|
372
|
+
marginal_size: float = 0.22,
|
|
373
|
+
marginal_pad: float = 0.03,
|
|
374
|
+
marginal_bins: Optional[int] = None,
|
|
375
|
+
marginal_color=None,
|
|
376
|
+
identity_line: Union[bool, dict] = False,
|
|
377
|
+
# identity
|
|
378
|
+
series: Optional[str] = None,
|
|
379
|
+
key: Optional[str] = None,
|
|
380
|
+
scale: Optional[str] = None,
|
|
381
|
+
alpha_by=None,
|
|
382
|
+
alpha_range=(0.25, 1.0),
|
|
383
|
+
alpha_norm: str = "linear",
|
|
384
|
+
vector_limit: Optional[int] = 5000,
|
|
385
|
+
label_axes: bool = True,
|
|
386
|
+
zorder: float = 2.0,
|
|
387
|
+
) -> HexMatrixResult:
|
|
388
|
+
"""Draw a hexmatrix: hexagonal bins of a point cloud, or a 2D array on a hex lattice.
|
|
389
|
+
|
|
390
|
+
Parameters
|
|
391
|
+
----------
|
|
392
|
+
data
|
|
393
|
+
A pandas or polars DataFrame, a dict of columns, or ``None`` when ``x``/``y``/``C`` are
|
|
394
|
+
passed as arrays.
|
|
395
|
+
x, y
|
|
396
|
+
Column names (or array-likes) of the point coordinates. Rows with a missing / non-finite
|
|
397
|
+
coordinate (or a non-positive one on a log axis) are dropped and counted in the manifest.
|
|
398
|
+
C
|
|
399
|
+
Optional third variable (column name or array). Each hexagon is coloured by ``reduce`` of the
|
|
400
|
+
``C`` values that fall in it instead of by a count — a 2D gradient / mean map.
|
|
401
|
+
matrix
|
|
402
|
+
Instead of points: a 2D array drawn one hexagon per entry on an offset lattice (row ``r`` of
|
|
403
|
+
the array is lattice row ``r``; odd rows are shifted half a hexagon). ``NaN`` entries are left
|
|
404
|
+
empty. Excludes ``x``/``y``/``C``.
|
|
405
|
+
ax
|
|
406
|
+
Target axes (default: the current axes).
|
|
407
|
+
|
|
408
|
+
gridsize, binwidth
|
|
409
|
+
Hexagon size: ``gridsize`` hexagons across the x extent (default ``30``), or ``binwidth``, the
|
|
410
|
+
centre-to-centre distance of neighbouring hexagons in (scaled) x data units — e.g. ``0.1`` for
|
|
411
|
+
0.1 mm bins of spatial data, or ``0.1`` decades on a log axis. ``binwidth`` wins.
|
|
412
|
+
extent
|
|
413
|
+
``(xmin, xmax, ymin, ymax)`` of the binned region in data units (default: the data range).
|
|
414
|
+
Points outside are dropped. Fix it to make hexagon names (and colours) comparable across
|
|
415
|
+
plots of different data.
|
|
416
|
+
orientation
|
|
417
|
+
``"pointy"`` (pointy-top hexagons in horizontal rows — default) or ``"flat"``.
|
|
418
|
+
aspect
|
|
419
|
+
``"auto"`` (default): hexagons are regular on the page for the axes' current box shape, whose
|
|
420
|
+
aspect is then locked (``Axes.set_box_aspect``) so a later layout pass cannot distort them.
|
|
421
|
+
``"equal"`` (or a number, the y-per-x data ratio): hexagons are regular in data units and the
|
|
422
|
+
axes' data aspect is set — right for spatial coordinates.
|
|
423
|
+
xscale, yscale
|
|
424
|
+
``"linear"`` or ``"log"`` (default: the axes' current scale). Log axes bin in log space, so the
|
|
425
|
+
hexagons are regular as displayed.
|
|
426
|
+
origin
|
|
427
|
+
Matrix mode only: ``"upper"`` (row 0 at the top, like ``imshow`` — default) or ``"lower"``.
|
|
428
|
+
|
|
429
|
+
stat
|
|
430
|
+
Without ``C``: what a hexagon's colour counts — ``"count"`` (default), ``"density"`` (count per
|
|
431
|
+
unit (scaled) data area, integrating to 1), ``"probability"`` (fraction of all points) or
|
|
432
|
+
``"percent"``.
|
|
433
|
+
reduce
|
|
434
|
+
With ``C``: ``"mean"`` (default), ``"median"``, ``"sum"``, ``"min"``, ``"max"``, ``"std"``,
|
|
435
|
+
``"count"`` or any callable taking a 1D array.
|
|
436
|
+
weights
|
|
437
|
+
Optional per-point weights (column name or array) for the count statistics.
|
|
438
|
+
mincnt
|
|
439
|
+
Minimum number of observations for a hexagon to be drawn (default ``1``: empty hexagons are
|
|
440
|
+
left empty). ``0`` draws every hexagon of the extent, empty ones at a count of zero.
|
|
441
|
+
|
|
442
|
+
cmap, color
|
|
443
|
+
The colormap: any matplotlib or fluxplot colormap name (``"viridis"``, ``"emerald"``,
|
|
444
|
+
``"crameri.batlow"``) or a Colormap; default: the style's image colormap. Or ``color``: one
|
|
445
|
+
colour, from which a pale → colour → deep single-hue ramp is built.
|
|
446
|
+
norm
|
|
447
|
+
How values map to colour: ``"linear"`` (default), ``"log"``, ``"sqrt"`` (a power law,
|
|
448
|
+
``gamma``), or any matplotlib ``Normalize`` instance (``SymLogNorm``, ``BoundaryNorm``, …).
|
|
449
|
+
vmin, vmax
|
|
450
|
+
Colour limits (default: the range of the drawn values). Flux can override them (and ``cmap``)
|
|
451
|
+
through the recipe's colour controls.
|
|
452
|
+
robust
|
|
453
|
+
Derive default limits from the 2nd–98th percentile (``True``) or a ``(low, high)`` percentile
|
|
454
|
+
pair instead of the extremes, so a few outlying hexagons do not wash out the rest.
|
|
455
|
+
center
|
|
456
|
+
A value to centre a diverging map on (``TwoSlopeNorm``); default limits become symmetric.
|
|
457
|
+
gamma
|
|
458
|
+
Exponent of ``norm="sqrt"`` (default ``0.5``).
|
|
459
|
+
|
|
460
|
+
gap
|
|
461
|
+
Fraction of each hexagon trimmed away to leave a gap between neighbours (``0`` — seamless —
|
|
462
|
+
to ``<1``).
|
|
463
|
+
edgecolor, linewidth
|
|
464
|
+
Hexagon outline: ``"face"`` (the fill colour — default; closes antialiasing seams), ``"none"``
|
|
465
|
+
or a colour; and its width in points (default ``0.25`` when seamless, ``0`` with a gap).
|
|
466
|
+
alpha
|
|
467
|
+
Hexagon opacity.
|
|
468
|
+
|
|
469
|
+
sparse
|
|
470
|
+
Draw hexagons only where at least ``sparse`` observations fall, and the individual points
|
|
471
|
+
elsewhere — a density map whose sparse fringe stays honest.
|
|
472
|
+
show_points
|
|
473
|
+
Overlay every observation as a small point.
|
|
474
|
+
point_size, point_color
|
|
475
|
+
Marker area (points²) and colour of those points (default: the colour of the lowest value).
|
|
476
|
+
|
|
477
|
+
colorbar, colorbar_label
|
|
478
|
+
Attach a named colour key (default ``True``) and its label (default: the statistic, e.g.
|
|
479
|
+
``"Count per hexbin"``, or ``"mean <C>"``).
|
|
480
|
+
colorbar_size, colorbar_pad
|
|
481
|
+
Its width and gap, as fractions of the plot's width.
|
|
482
|
+
marginals
|
|
483
|
+
``True`` / ``"hist"``: histograms of ``x`` and ``y`` along the top and right (points mode).
|
|
484
|
+
marginal_size, marginal_pad
|
|
485
|
+
Their depth and gap, as fractions of the plot's size.
|
|
486
|
+
marginal_bins, marginal_color
|
|
487
|
+
Number of marginal bins across the x extent (default: ``gridsize``; y gets the same bin
|
|
488
|
+
width) and their colour (default: a mid tone of the colormap, or ``color``).
|
|
489
|
+
identity_line
|
|
490
|
+
Draw the ``y = x`` line across the plot (``True``, or a dict of ``Line2D`` properties).
|
|
491
|
+
|
|
492
|
+
series
|
|
493
|
+
The series name — the root of every part id (default ``"hexbin"``).
|
|
494
|
+
key
|
|
495
|
+
The recipe key of the colour controls (default: the series).
|
|
496
|
+
scale
|
|
497
|
+
Join a shared colour scale declared with :func:`fluxplot.color_scale` (its map, norm and
|
|
498
|
+
union limits; one key for every panel).
|
|
499
|
+
alpha_by, alpha_range, alpha_norm
|
|
500
|
+
Value × confidence: wash out hexagons by a second variable. ``"count"`` (observations
|
|
501
|
+
per hexagon), a column of ``data`` or a per-point array (its mean per hexagon), a
|
|
502
|
+
per-hexagon array (one value per drawn hexagon), or a matrix of the ``matrix`` shape.
|
|
503
|
+
Alpha runs over ``alpha_range`` with the value (``alpha_norm="log"`` for p-values); the
|
|
504
|
+
manifest records ``colorScales[].alpha`` and every hexagon carries ``data-alpha-value``.
|
|
505
|
+
vector_limit
|
|
506
|
+
Hexagons stay individually addressable up to this many (default 5000; ``None`` for the
|
|
507
|
+
save's generic threshold), where a generic collection would be rasterized at 800.
|
|
508
|
+
label_axes
|
|
509
|
+
Label the axes with the ``x`` / ``y`` column names (default ``True``).
|
|
510
|
+
zorder
|
|
511
|
+
z-order of the hexagons; points and the identity line sit above.
|
|
512
|
+
|
|
513
|
+
Returns
|
|
514
|
+
-------
|
|
515
|
+
HexMatrixResult
|
|
516
|
+
The axes, the hexagons, the bins drawn, the colour scale, the colorbar and the marginal axes.
|
|
517
|
+
"""
|
|
518
|
+
import matplotlib.pyplot as plt
|
|
519
|
+
from matplotlib.collections import PolyCollection
|
|
520
|
+
|
|
521
|
+
from ..fields import _options
|
|
522
|
+
from ..fields import colorbar as _colorbar
|
|
523
|
+
|
|
524
|
+
who = "hexmatrix"
|
|
525
|
+
if ax is None:
|
|
526
|
+
ax = plt.gca()
|
|
527
|
+
if orientation not in ("pointy", "flat"):
|
|
528
|
+
raise ValueError(f"{who}: orientation must be 'pointy' or 'flat'; got {orientation!r}")
|
|
529
|
+
if stat not in _STATS:
|
|
530
|
+
raise ValueError(f"{who}: stat must be one of {_STATS}; got {stat!r}")
|
|
531
|
+
if not (isinstance(norm, str) and norm in _NORMS) and not hasattr(norm, "autoscale_None"):
|
|
532
|
+
raise ValueError(f"{who}: norm must be one of {_NORMS} or a matplotlib Normalize; got {norm!r}")
|
|
533
|
+
if not 0.0 <= gap < 1.0:
|
|
534
|
+
raise ValueError(f"{who}: gap must be in [0, 1); got {gap!r}")
|
|
535
|
+
if cmap is not None and color is not None:
|
|
536
|
+
raise ValueError(f"{who}: give cmap or color, not both")
|
|
537
|
+
if marginals not in (False, True, "hist"):
|
|
538
|
+
raise ValueError(f"{who}: marginals must be False, True or 'hist'; got {marginals!r}")
|
|
539
|
+
series = str(series) if series is not None else "hexbin"
|
|
540
|
+
matrix_mode = matrix is not None
|
|
541
|
+
if matrix_mode and any(v is not None for v in (x, y, C, weights)):
|
|
542
|
+
raise ValueError(f"{who}: matrix= excludes x, y, C and weights")
|
|
543
|
+
if not matrix_mode and (x is None or y is None):
|
|
544
|
+
raise ValueError(f"{who}: give x and y (points), or matrix= (a 2D array)")
|
|
545
|
+
if matrix_mode and marginals:
|
|
546
|
+
raise ValueError(f"{who}: marginals need points (x, y), not a matrix")
|
|
547
|
+
|
|
548
|
+
xscale = xscale or ax.get_xscale()
|
|
549
|
+
yscale = yscale or ax.get_yscale()
|
|
550
|
+
for name, sc in (("xscale", xscale), ("yscale", yscale)):
|
|
551
|
+
if sc not in ("linear", "log"):
|
|
552
|
+
raise ValueError(f"{who}: {name} must be 'linear' or 'log'; got {sc!r}")
|
|
553
|
+
ax.set_xscale(xscale)
|
|
554
|
+
ax.set_yscale(yscale)
|
|
555
|
+
fwd = _Lattice.fwd
|
|
556
|
+
|
|
557
|
+
# ---- the lattice ------------------------------------------------------------------------------------
|
|
558
|
+
info: dict = {}
|
|
559
|
+
if matrix_mode:
|
|
560
|
+
M = np.ma.masked_invalid(np.ma.asarray(matrix, dtype=float))
|
|
561
|
+
if M.ndim != 2 or not M.size:
|
|
562
|
+
raise ValueError(f"{who}: matrix must be a nonempty 2D array")
|
|
563
|
+
if origin not in ("upper", "lower"):
|
|
564
|
+
raise ValueError(f"{who}: origin must be 'upper' or 'lower'; got {origin!r}")
|
|
565
|
+
nr, nc = M.shape
|
|
566
|
+
# unit radius, data units = unit units; flip rows for origin="upper" without renaming them
|
|
567
|
+
lat = _Lattice(R=1.0, orientation=orientation, x0=0.0, sx=1.0,
|
|
568
|
+
y0=0.0, sy=1.0, xscale="linear", yscale="linear")
|
|
569
|
+
rr, cc = np.meshgrid(np.arange(nr), np.arange(nc), indexing="ij")
|
|
570
|
+
rows, cols = rr.ravel(), cc.ravel()
|
|
571
|
+
vals = M.filled(np.nan).ravel()
|
|
572
|
+
keep = np.isfinite(vals)
|
|
573
|
+
rows, cols, value = rows[keep], cols[keep], vals[keep]
|
|
574
|
+
counts = None
|
|
575
|
+
alpha_src = None
|
|
576
|
+
if alpha_by is not None:
|
|
577
|
+
if isinstance(alpha_by, str):
|
|
578
|
+
raise ValueError(f"{who}: alpha_by must be a matrix of shape {M.shape} in matrix mode")
|
|
579
|
+
A = np.ma.filled(np.ma.asarray(alpha_by, dtype=float), np.nan)
|
|
580
|
+
if A.shape != M.shape:
|
|
581
|
+
raise ValueError(f"{who}: alpha_by must have the matrix shape {M.shape}, got {A.shape}")
|
|
582
|
+
alpha_src = A.ravel()[keep]
|
|
583
|
+
u, v = lat.centre(rr.ravel(), cc.ravel())
|
|
584
|
+
if origin == "upper":
|
|
585
|
+
lat.sy, lat.y0 = -1.0, float(v.max()) # v → -(Y - y0): row 0 at the top
|
|
586
|
+
xs_all, ys_all = lat.to_data(u, v)
|
|
587
|
+
pad = 1.0
|
|
588
|
+
xlim = (float(xs_all.min()) - pad, float(xs_all.max()) + pad)
|
|
589
|
+
ylim = (float(ys_all.min()) - pad, float(ys_all.max()) + pad)
|
|
590
|
+
ax.set_aspect("equal")
|
|
591
|
+
n_used = dropped = None
|
|
592
|
+
info.update(mode="matrix", shape=[int(nr), int(nc)], origin=origin)
|
|
593
|
+
x_name = y_name = c_name = None
|
|
594
|
+
else:
|
|
595
|
+
xv, x_name = _column(who, data, x, "x")
|
|
596
|
+
yv, y_name = _column(who, data, y, "y")
|
|
597
|
+
cv, c_name = _column(who, data, C, "C")
|
|
598
|
+
wv, _w = _column(who, data, weights, "weights")
|
|
599
|
+
xs = _floats(who, xv, "x")
|
|
600
|
+
ys = _floats(who, yv, "y")
|
|
601
|
+
n = len(xs)
|
|
602
|
+
if len(ys) != n:
|
|
603
|
+
raise ValueError(f"{who}: x and y have different lengths ({n} vs {len(ys)})")
|
|
604
|
+
cs = _floats(who, cv, "C") if cv is not None else None
|
|
605
|
+
ws = _floats(who, wv, "weights") if wv is not None else None
|
|
606
|
+
for arr, what in ((cs, "C"), (ws, "weights")):
|
|
607
|
+
if arr is not None and len(arr) != n:
|
|
608
|
+
raise ValueError(f"{who}: {what} has {len(arr)} values for {n} points")
|
|
609
|
+
ok = np.isfinite(xs) & np.isfinite(ys)
|
|
610
|
+
if xscale == "log":
|
|
611
|
+
ok &= xs > 0
|
|
612
|
+
if yscale == "log":
|
|
613
|
+
ok &= ys > 0
|
|
614
|
+
if cs is not None:
|
|
615
|
+
ok &= np.isfinite(cs)
|
|
616
|
+
if ws is not None:
|
|
617
|
+
ok &= np.isfinite(ws)
|
|
618
|
+
if extent is not None:
|
|
619
|
+
if len(extent) != 4:
|
|
620
|
+
raise ValueError(f"{who}: extent must be (xmin, xmax, ymin, ymax)")
|
|
621
|
+
e = [float(v) for v in extent]
|
|
622
|
+
ok &= (xs >= e[0]) & (xs <= e[1]) & (ys >= e[2]) & (ys <= e[3])
|
|
623
|
+
if not ok.any():
|
|
624
|
+
raise ValueError(f"{who}: no finite points to bin")
|
|
625
|
+
dropped = int(n - ok.sum())
|
|
626
|
+
xs, ys = xs[ok], ys[ok]
|
|
627
|
+
cs = cs[ok] if cs is not None else None
|
|
628
|
+
ws = ws[ok] if ws is not None else None
|
|
629
|
+
n_used = int(xs.size)
|
|
630
|
+
X, Y = fwd(xs, xscale), fwd(ys, yscale)
|
|
631
|
+
if extent is not None:
|
|
632
|
+
X0, X1 = fwd(e[0], xscale), fwd(e[1], xscale)
|
|
633
|
+
Y0, Y1 = fwd(e[2], yscale), fwd(e[3], yscale)
|
|
634
|
+
else:
|
|
635
|
+
X0, X1, Y0, Y1 = float(X.min()), float(X.max()), float(Y.min()), float(Y.max())
|
|
636
|
+
if X1 <= X0:
|
|
637
|
+
X0, X1 = X0 - 0.5, X1 + 0.5
|
|
638
|
+
if Y1 <= Y0:
|
|
639
|
+
Y0, Y1 = Y0 - 0.5, Y1 + 0.5
|
|
640
|
+
across = SQRT3 if orientation == "pointy" else 1.5 # x spacing of hexagon centres, per R
|
|
641
|
+
if isinstance(gridsize, bool) or not isinstance(gridsize, (int, np.integer)) or gridsize < 1:
|
|
642
|
+
raise ValueError(f"{who}: gridsize must be a positive integer; got {gridsize!r}")
|
|
643
|
+
if binwidth is not None and not (isinstance(binwidth, Real) and binwidth > 0):
|
|
644
|
+
raise ValueError(f"{who}: binwidth must be a positive number; got {binwidth!r}")
|
|
645
|
+
if aspect == "auto":
|
|
646
|
+
# unit plane = the axes box: u ∈ [0, 1] across the x limits, v ∈ [0, ρ] up the y limits,
|
|
647
|
+
# with one circumradius of padding all round so no hexagon is clipped
|
|
648
|
+
shared = len(ax.get_shared_x_axes().get_siblings(ax)) > 1 or len(ax.get_shared_y_axes().get_siblings(ax)) > 1
|
|
649
|
+
rho = ax.get_box_aspect()
|
|
650
|
+
if rho is None:
|
|
651
|
+
pos = ax.get_position()
|
|
652
|
+
fw, fh = ax.figure.get_size_inches()
|
|
653
|
+
rho = (pos.height * fh) / (pos.width * fw)
|
|
654
|
+
rho = float(rho)
|
|
655
|
+
if binwidth is not None:
|
|
656
|
+
k = binwidth / SQRT3 / (X1 - X0)
|
|
657
|
+
R = k / (1 + 2 * k)
|
|
658
|
+
else:
|
|
659
|
+
R = 1.0 / (gridsize * across + 2.0)
|
|
660
|
+
if rho <= 2 * R:
|
|
661
|
+
raise ValueError(f"{who}: the axes are too flat for hexagons this large; raise gridsize")
|
|
662
|
+
sx, sy = (X1 - X0) / (1 - 2 * R), (Y1 - Y0) / (rho - 2 * R)
|
|
663
|
+
lat = _Lattice(R=R, orientation=orientation, x0=X0 - R * sx, sx=sx,
|
|
664
|
+
y0=Y0 - R * sy, sy=sy, xscale=xscale, yscale=yscale)
|
|
665
|
+
if shared:
|
|
666
|
+
# locking the box aspect of an axes that shares x or y would distort its siblings;
|
|
667
|
+
# the hexagons are regular now, but a later layout pass may squash them
|
|
668
|
+
warnings.warn(f"{who}: aspect='auto' on axes sharing x or y leaves the box aspect unlocked; "
|
|
669
|
+
"hexagons may be distorted by a later layout pass (pass aspect='equal' or a number)",
|
|
670
|
+
stacklevel=2)
|
|
671
|
+
else:
|
|
672
|
+
ax.set_box_aspect(rho)
|
|
673
|
+
info.update(boxAspect=rho)
|
|
674
|
+
else:
|
|
675
|
+
a = 1.0 if aspect == "equal" else aspect
|
|
676
|
+
if isinstance(a, bool) or not isinstance(a, Real) or a <= 0:
|
|
677
|
+
raise ValueError(f"{who}: aspect must be 'auto', 'equal' or a positive number; got {aspect!r}")
|
|
678
|
+
a = float(a)
|
|
679
|
+
R = (binwidth / SQRT3) if binwidth is not None else (X1 - X0) / (gridsize * across)
|
|
680
|
+
lat = _Lattice(R=R, orientation=orientation, x0=X0 - R, sx=1.0,
|
|
681
|
+
y0=Y0 - R * a, sy=a, xscale=xscale, yscale=yscale)
|
|
682
|
+
ax.set_aspect(a)
|
|
683
|
+
xlim = tuple(float(v) for v in lat.inv(np.array([X0 - lat.R * lat.sx, X1 + lat.R * lat.sx]), xscale))
|
|
684
|
+
ylim = tuple(float(v) for v in lat.inv(np.array([Y0 - lat.R * lat.sy, Y1 + lat.R * lat.sy]), yscale))
|
|
685
|
+
|
|
686
|
+
u, v = lat.to_unit(xs, ys)
|
|
687
|
+
rows, cols, counts, weighted, value, inv = _bin_points(lat, u, v, cs, ws, reduce)
|
|
688
|
+
per_point = counts[inv] # observations sharing each point's hexagon
|
|
689
|
+
# the alpha variable per hexagon: the count, or the mean of a per-point variable
|
|
690
|
+
alpha_src = None
|
|
691
|
+
if alpha_by is not None:
|
|
692
|
+
if isinstance(alpha_by, str) and alpha_by == "count":
|
|
693
|
+
alpha_src = counts.copy()
|
|
694
|
+
else:
|
|
695
|
+
av, _ = _column(who, data, alpha_by, "alpha_by") if isinstance(alpha_by, str) else (list(alpha_by), None)
|
|
696
|
+
av = np.asarray(_floats(who, av, "alpha_by"), dtype=float)
|
|
697
|
+
if av.size == n and n != len(rows): # one value per point: the mean per hexagon
|
|
698
|
+
av = av[ok]
|
|
699
|
+
ok = np.isfinite(av)
|
|
700
|
+
sums = np.bincount(inv[ok], weights=av[ok], minlength=len(counts))
|
|
701
|
+
nn = np.bincount(inv[ok], minlength=len(counts))
|
|
702
|
+
alpha_src = np.where(nn > 0, sums / np.maximum(nn, 1), np.nan)
|
|
703
|
+
elif av.size == len(rows):
|
|
704
|
+
alpha_src = av # one value per hexagon, in bin order (rows then cols)
|
|
705
|
+
else:
|
|
706
|
+
raise ValueError(f"{who}: alpha_by must be 'count', a column / array with one value per point "
|
|
707
|
+
f"({n}) or one per hexagon ({len(rows)}); got {av.size}")
|
|
708
|
+
if mincnt == 0:
|
|
709
|
+
er, ec = _lattice_cells(lat, lat.R, (X1 - X0) / lat.sx + lat.R,
|
|
710
|
+
lat.R, (Y1 - Y0) / lat.sy + lat.R)
|
|
711
|
+
have = set(zip(rows.tolist(), cols.tolist()))
|
|
712
|
+
extra = [(r, c) for r, c in zip(er.tolist(), ec.tolist()) if (r, c) not in have]
|
|
713
|
+
if extra:
|
|
714
|
+
er, ec = np.array(extra, dtype=int).T
|
|
715
|
+
rows = np.r_[rows, er]
|
|
716
|
+
cols = np.r_[cols, ec]
|
|
717
|
+
counts = np.r_[counts, np.zeros(len(er))]
|
|
718
|
+
weighted = np.r_[weighted, np.zeros(len(er))]
|
|
719
|
+
if value is not None:
|
|
720
|
+
value = np.r_[value, np.full(len(er), np.nan)]
|
|
721
|
+
if alpha_src is not None:
|
|
722
|
+
alpha_src = np.r_[alpha_src, np.full(len(er), np.nan)]
|
|
723
|
+
order = np.lexsort((cols, rows))
|
|
724
|
+
rows, cols, counts, weighted = rows[order], cols[order], counts[order], weighted[order]
|
|
725
|
+
value = value[order] if value is not None else None
|
|
726
|
+
alpha_src = alpha_src[order] if alpha_src is not None else None
|
|
727
|
+
if value is None:
|
|
728
|
+
total = float(weighted.sum()) or 1.0
|
|
729
|
+
value = {"count": weighted, "probability": weighted / total, "percent": 100 * weighted / total,
|
|
730
|
+
"density": weighted / (total * lat.area())}[stat]
|
|
731
|
+
# which hexagons are drawn; the points of the rest (sparse) are drawn as points
|
|
732
|
+
drawn = counts >= max(int(mincnt), 0)
|
|
733
|
+
sparse_pts = None
|
|
734
|
+
if sparse is not None:
|
|
735
|
+
drawn &= counts >= int(sparse)
|
|
736
|
+
sparse_pts = per_point < int(sparse)
|
|
737
|
+
rows, cols, counts, value = rows[drawn], cols[drawn], counts[drawn], np.asarray(value)[drawn]
|
|
738
|
+
alpha_src = alpha_src[drawn] if alpha_src is not None else None
|
|
739
|
+
info.update(mode="points", n=n_used, dropped=dropped, stat=None if cs is not None else stat,
|
|
740
|
+
reduce=None if cs is None else _reducer(reduce)[1],
|
|
741
|
+
weighted=ws is not None, mincnt=int(mincnt),
|
|
742
|
+
sparse=None if sparse is None else int(sparse),
|
|
743
|
+
extent=[_plain(float(v)) for v in (lat.inv(X0, xscale), lat.inv(X1, xscale),
|
|
744
|
+
lat.inv(Y0, yscale), lat.inv(Y1, yscale))],
|
|
745
|
+
hexArea=float(lat.area()))
|
|
746
|
+
|
|
747
|
+
ax.set_xlim(*xlim)
|
|
748
|
+
ax.set_ylim(*ylim)
|
|
749
|
+
if not matrix_mode:
|
|
750
|
+
for axis, sc, lims in ((ax.xaxis, xscale, xlim), (ax.yaxis, yscale, ylim)):
|
|
751
|
+
if sc == "log":
|
|
752
|
+
_plain_log_ticks(axis, *lims)
|
|
753
|
+
|
|
754
|
+
# ---- the colour scale ------------------------------------------------------------------------------
|
|
755
|
+
dark_ground = _ground_is_dark(ax)
|
|
756
|
+
base_cmap = _mono_cmap(color, dark=dark_ground) if color is not None else _resolve_cmap(cmap)
|
|
757
|
+
norm_kind = norm if isinstance(norm, str) else None
|
|
758
|
+
lo, hi = _auto_limits(np.asarray(value, dtype=float), norm_kind, robust, center)
|
|
759
|
+
if hasattr(norm, "autoscale_None"):
|
|
760
|
+
lo = norm.vmin if norm.vmin is not None else lo
|
|
761
|
+
hi = norm.vmax if norm.vmax is not None else hi
|
|
762
|
+
opts = {"cmap": base_cmap.name, "vmin": vmin if vmin is not None else lo,
|
|
763
|
+
"vmax": vmax if vmax is not None else hi,
|
|
764
|
+
"norm": _make_norm(norm, center, None, None, gamma)}
|
|
765
|
+
control_key = _options(ax, series, key, opts, resolve=_resolve_cmap, scale=scale) # any Flux recipe override
|
|
766
|
+
cb_extend = opts.pop("_extend", None)
|
|
767
|
+
the_cmap = base_cmap if opts["cmap"] == base_cmap.name else _resolve_cmap(opts["cmap"])
|
|
768
|
+
the_norm = opts["norm"]
|
|
769
|
+
if "vmin" in opts: # _options moved the limits onto the (copied) Normalize itself
|
|
770
|
+
the_norm.vmin, the_norm.vmax = opts["vmin"], opts["vmax"]
|
|
771
|
+
if scale is not None and the_norm.vmin is None: # a shared scale's limits are resolved at save
|
|
772
|
+
the_norm.vmin, the_norm.vmax = lo, hi
|
|
773
|
+
if center is not None and not (the_norm.vmin < center < the_norm.vmax):
|
|
774
|
+
raise ValueError(f"{who}: center must lie between vmin and vmax "
|
|
775
|
+
f"(center={center!r}, vmin={the_norm.vmin!r}, vmax={the_norm.vmax!r})")
|
|
776
|
+
|
|
777
|
+
# ---- the hexagons ----------------------------------------------------------------------------------
|
|
778
|
+
polys = lat.polygons(rows, cols, shrink=1.0 - gap)
|
|
779
|
+
lw = linewidth if linewidth is not None else (0.25 if gap == 0 else 0.0)
|
|
780
|
+
hexes = PolyCollection(list(polys), array=np.asarray(value, dtype=float), cmap=the_cmap,
|
|
781
|
+
norm=the_norm, edgecolors=edgecolor, linewidths=lw, alpha=alpha,
|
|
782
|
+
zorder=zorder)
|
|
783
|
+
ax.add_collection(hexes, autolim=False)
|
|
784
|
+
if scale is not None:
|
|
785
|
+
from ..fields import join_scale
|
|
786
|
+
join_scale(ax, scale, hexes, np.asarray(value, dtype=float))
|
|
787
|
+
alpha_data: dict = {}
|
|
788
|
+
if alpha_src is not None:
|
|
789
|
+
from ..fields import apply_alpha
|
|
790
|
+
source = alpha_by if isinstance(alpha_by, str) else "alpha_by"
|
|
791
|
+
apply_alpha(hexes, alpha_data, alpha_src, alpha_range=alpha_range, alpha_norm=alpha_norm, source=source)
|
|
792
|
+
cx, cy = lat.to_data(*lat.centre(rows, cols))
|
|
793
|
+
bins = {"row": rows.astype(int), "col": cols.astype(int), "x": np.asarray(cx, dtype=float),
|
|
794
|
+
"y": np.asarray(cy, dtype=float), "count": counts, "value": np.asarray(value, dtype=float)}
|
|
795
|
+
names = [f"{int(r)}.{int(c)}" for r, c in zip(rows, cols)]
|
|
796
|
+
boxes = [(float(np.min(v[:, 0])), float(np.max(v[:, 0])), float(np.min(v[:, 1])), float(np.max(v[:, 1])))
|
|
797
|
+
for v in (np.asarray(poly, dtype=float) for poly in polys)] # each hexagon's data-space box
|
|
798
|
+
attrs = [{"data_row": int(r), "data_column": int(c), "data_key": f"{int(r)}.{int(c)}", "data_x": _plain(float(px)),
|
|
799
|
+
"data_y": _plain(float(py)), "data_count": None if counts is None else _plain(float(k)),
|
|
800
|
+
"data_value": _plain(float(val)),
|
|
801
|
+
"data_x0": _plain(b[0]), "data_x1": _plain(b[1]), "data_y0": _plain(b[2]), "data_y1": _plain(b[3])}
|
|
802
|
+
for r, c, px, py, k, val, b in zip(rows, cols, cx, cy,
|
|
803
|
+
counts if counts is not None else [None] * len(rows), value, boxes)]
|
|
804
|
+
if alpha_src is not None:
|
|
805
|
+
for a, av in zip(attrs, alpha_src):
|
|
806
|
+
a["data_alpha_value"] = None if not np.isfinite(av) else _plain(float(av))
|
|
807
|
+
payload = {"orientation": orientation, "hexRadius": float(lat.R),
|
|
808
|
+
"aspect": aspect if isinstance(aspect, str) else float(aspect),
|
|
809
|
+
"scale": {"x": xscale if not matrix_mode else "linear",
|
|
810
|
+
"y": yscale if not matrix_mode else "linear"},
|
|
811
|
+
"gridsize": None if (matrix_mode or binwidth is not None) else int(gridsize),
|
|
812
|
+
"binwidth": None if binwidth is None else float(binwidth),
|
|
813
|
+
"valueLabel": None, "nBins": int(len(rows)), **info,
|
|
814
|
+
"bins": [{"row": int(r), "col": int(c), "x": _plain(float(px)), "y": _plain(float(py)),
|
|
815
|
+
"count": None if counts is None else _plain(float(k)),
|
|
816
|
+
"value": _plain(float(val))}
|
|
817
|
+
for r, c, px, py, k, val in zip(rows, cols, cx, cy,
|
|
818
|
+
counts if counts is not None else [None] * len(rows),
|
|
819
|
+
value)]}
|
|
820
|
+
reg = _tagger.registry_for(ax.figure)
|
|
821
|
+
field_config = {"kind": "hexbin", "controlKey": control_key, "shape": [int(len(rows))]}
|
|
822
|
+
if cb_extend:
|
|
823
|
+
field_config["extend"] = cb_extend
|
|
824
|
+
mark_data = {"field_config": field_config, "field_artist": hexes, "field_resolve": _resolve_cmap,
|
|
825
|
+
"field_names": names, "field_member_prefix": "hex",
|
|
826
|
+
"field_member_role": "x-hex", "field_attrs": attrs, "hexmatrix": payload, **alpha_data}
|
|
827
|
+
if vector_limit is not None:
|
|
828
|
+
mark_data["raster_threshold"] = int(vector_limit) # hexagons stay addressable up to this many
|
|
829
|
+
reg.add(Mark(role="x-hexbin", series=series, name="hexes", kind="hexmatrix", artists=[hexes], data=mark_data))
|
|
830
|
+
artists = {"hexes": hexes, "lattice": lat}
|
|
831
|
+
|
|
832
|
+
# ---- points ---------------------------------------------------------------------------------------
|
|
833
|
+
points = None
|
|
834
|
+
if not matrix_mode and (show_points or sparse is not None):
|
|
835
|
+
sel = np.ones(n_used, bool) if show_points else sparse_pts
|
|
836
|
+
if sel.any():
|
|
837
|
+
pc = point_color if point_color is not None else the_cmap(0.0)
|
|
838
|
+
points = ax.scatter(xs[sel], ys[sel], s=point_size, color=[pc], linewidths=0,
|
|
839
|
+
zorder=zorder + 0.5)
|
|
840
|
+
reg.add(Mark(role="point", series=f"{series}-points", kind="scatter", live_data=True,
|
|
841
|
+
artists=[points], indexed=True))
|
|
842
|
+
artists["points"] = points
|
|
843
|
+
|
|
844
|
+
# ---- identity line --------------------------------------------------------------------------------
|
|
845
|
+
if identity_line:
|
|
846
|
+
lo_d, hi_d = max(xlim[0], ylim[0]), min(xlim[1], ylim[1])
|
|
847
|
+
if lo_d < hi_d:
|
|
848
|
+
props = {"color": "0.7" if dark_ground else "0.35", "lw": 0.8, "ls": (0, (4, 3)), "zorder": zorder + 1}
|
|
849
|
+
if isinstance(identity_line, dict):
|
|
850
|
+
props.update(identity_line)
|
|
851
|
+
(line,) = ax.plot([lo_d, hi_d], [lo_d, hi_d], **props)
|
|
852
|
+
reg.add(Mark(role="reference-line", name="identity", artists=[line],
|
|
853
|
+
data={"x": None, "y": None, "slope": 1.0, "intercept": 0.0}))
|
|
854
|
+
artists["identity"] = line
|
|
855
|
+
|
|
856
|
+
# ---- labels ---------------------------------------------------------------------------------------
|
|
857
|
+
if label_axes and not matrix_mode:
|
|
858
|
+
if x_name and not ax.get_xlabel():
|
|
859
|
+
ax.set_xlabel(x_name)
|
|
860
|
+
if y_name and not ax.get_ylabel():
|
|
861
|
+
ax.set_ylabel(y_name)
|
|
862
|
+
if matrix_mode:
|
|
863
|
+
ax.set_xticks([])
|
|
864
|
+
ax.set_yticks([])
|
|
865
|
+
for sp in ax.spines.values():
|
|
866
|
+
sp.set_visible(False)
|
|
867
|
+
if colorbar_label is None:
|
|
868
|
+
if matrix_mode:
|
|
869
|
+
colorbar_label = ""
|
|
870
|
+
elif cs is not None:
|
|
871
|
+
colorbar_label = f"{info['reduce']} {c_name}" if c_name else info["reduce"]
|
|
872
|
+
else:
|
|
873
|
+
colorbar_label = {"count": "Count per hexbin", "density": "Density",
|
|
874
|
+
"probability": "Probability", "percent": "Percent"}[stat]
|
|
875
|
+
payload["valueLabel"] = colorbar_label or None
|
|
876
|
+
field_config["label"] = colorbar_label or None
|
|
877
|
+
|
|
878
|
+
# ---- marginals ------------------------------------------------------------------------------------
|
|
879
|
+
marginal_axes = {}
|
|
880
|
+
right_edge = 1.0
|
|
881
|
+
if marginals:
|
|
882
|
+
from ..api import hist as _hist
|
|
883
|
+
from ..panels import panel as _panel
|
|
884
|
+
mcol = marginal_color if marginal_color is not None else (
|
|
885
|
+
color if color is not None else the_cmap(0.6))
|
|
886
|
+
nb = int(marginal_bins or gridsize)
|
|
887
|
+
width = (X1 - X0) / nb
|
|
888
|
+
ex = lat.inv(np.linspace(X0, X1, nb + 1), xscale)
|
|
889
|
+
ey = lat.inv(np.arange(Y0, Y1 + width, width) if Y1 > Y0 else np.array([Y0, Y1]), yscale)
|
|
890
|
+
top = ax.inset_axes([0, 1 + marginal_pad, 1, marginal_size], sharex=ax)
|
|
891
|
+
side = ax.inset_axes([1 + marginal_pad, 0, marginal_size, 1], sharey=ax)
|
|
892
|
+
edge = ax.get_facecolor()
|
|
893
|
+
_hist(top, xs, series=f"{series}-x", bins=ex, color=mcol, edgecolor=edge, linewidth=0.4)
|
|
894
|
+
_hist(side, ys, series=f"{series}-y", bins=ey, color=mcol, edgecolor=edge, linewidth=0.4,
|
|
895
|
+
orientation="horizontal")
|
|
896
|
+
for m_ax, base in ((top, "bottom"), (side, "left")):
|
|
897
|
+
for name_, sp in m_ax.spines.items():
|
|
898
|
+
sp.set_visible(name_ == base)
|
|
899
|
+
m_ax.tick_params(axis="both", which="both", left=base == "left", bottom=base == "bottom",
|
|
900
|
+
labelleft=False, labelbottom=False, top=False, right=False)
|
|
901
|
+
m_ax.set_xlabel("")
|
|
902
|
+
m_ax.set_ylabel("")
|
|
903
|
+
m_ax.patch.set_alpha(0)
|
|
904
|
+
top.set_yticks([])
|
|
905
|
+
side.set_xticks([])
|
|
906
|
+
_panel(top, f"{series} x-marginal")
|
|
907
|
+
_panel(side, f"{series} y-marginal")
|
|
908
|
+
marginal_axes = {"x": top, "y": side}
|
|
909
|
+
artists["marginals"] = marginal_axes
|
|
910
|
+
right_edge = 1 + marginal_pad + marginal_size
|
|
911
|
+
|
|
912
|
+
# ---- colour key -----------------------------------------------------------------------------------
|
|
913
|
+
cb = None
|
|
914
|
+
if colorbar:
|
|
915
|
+
cax = ax.inset_axes([right_edge + colorbar_pad, 0, colorbar_size, 1])
|
|
916
|
+
cb = _colorbar(hexes, name="color", ax=ax, cax=cax)
|
|
917
|
+
if colorbar_label:
|
|
918
|
+
cb.set_label(colorbar_label)
|
|
919
|
+
if type(the_norm).__name__ == "LogNorm" and the_norm.vmin and the_norm.vmax:
|
|
920
|
+
decades = np.log10(the_norm.vmax / the_norm.vmin)
|
|
921
|
+
_plain_log_ticks(cax.yaxis, the_norm.vmin, the_norm.vmax,
|
|
922
|
+
subs=(1.0, 3.0) if decades <= 3 else (1.0,))
|
|
923
|
+
artists["colorbar"] = cb
|
|
924
|
+
|
|
925
|
+
return HexMatrixResult(ax=ax, hexes=hexes, bins=bins, cmap=the_cmap, norm=the_norm,
|
|
926
|
+
series=series, control_key=control_key, colorbar=cb,
|
|
927
|
+
marginal_axes=marginal_axes, points=points, artists=artists)
|