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.
Files changed (65) hide show
  1. fluxplot/__init__.py +115 -0
  2. fluxplot/_fieldmap.py +97 -0
  3. fluxplot/_mesh_reduce.py +54 -0
  4. fluxplot/_scene3d_size.py +95 -0
  5. fluxplot/_viewer/THIRD-PARTY.txt +23 -0
  6. fluxplot/_viewer/flux-model3d-viewer.min.js +4221 -0
  7. fluxplot/_viewer/stamp.json +4 -0
  8. fluxplot/api.py +1196 -0
  9. fluxplot/autotag.py +164 -0
  10. fluxplot/base.mplstyle +0 -0
  11. fluxplot/brackets.py +242 -0
  12. fluxplot/canonical_json.py +23 -0
  13. fluxplot/capture.py +150 -0
  14. fluxplot/colorcheck.py +285 -0
  15. fluxplot/colors.py +727 -0
  16. fluxplot/colorscale.py +477 -0
  17. fluxplot/data.py +178 -0
  18. fluxplot/definitions/colormaps.json +1639 -0
  19. fluxplot/definitions/flexoki.tokens.json +2571 -0
  20. fluxplot/definitions/palettes.json +2547 -0
  21. fluxplot/descriptors.py +87 -0
  22. fluxplot/fields.py +611 -0
  23. fluxplot/fits.py +240 -0
  24. fluxplot/glb.py +84 -0
  25. fluxplot/ids.py +173 -0
  26. fluxplot/images.py +362 -0
  27. fluxplot/integrity.py +27 -0
  28. fluxplot/manifest.py +788 -0
  29. fluxplot/mesh3d.py +376 -0
  30. fluxplot/panels.py +284 -0
  31. fluxplot/postprocess.py +638 -0
  32. fluxplot/presets.py +66 -0
  33. fluxplot/provenance.py +177 -0
  34. fluxplot/raster.py +295 -0
  35. fluxplot/recipe.py +178 -0
  36. fluxplot/render.py +66 -0
  37. fluxplot/roles.py +147 -0
  38. fluxplot/scene3d.py +386 -0
  39. fluxplot/scene3d_manifest.py +112 -0
  40. fluxplot/scene3d_viewer.py +633 -0
  41. fluxplot/schemas/.gitkeep +0 -0
  42. fluxplot/schemas/manifest.schema.json +2479 -0
  43. fluxplot/schemas/recipe.schema.json +179 -0
  44. fluxplot/schemas/scene3d.schema.json +461 -0
  45. fluxplot/seaborn_adapters.py +323 -0
  46. fluxplot/signature_fluxplots/__init__.py +18 -0
  47. fluxplot/signature_fluxplots/_colour.py +412 -0
  48. fluxplot/signature_fluxplots/fluxbox.py +433 -0
  49. fluxplot/signature_fluxplots/glowbar.py +769 -0
  50. fluxplot/signature_fluxplots/hexmatrix.py +927 -0
  51. fluxplot/stats/__init__.py +63 -0
  52. fluxplot/stats/_common.py +196 -0
  53. fluxplot/stats/multi_group.py +443 -0
  54. fluxplot/stats/paired.py +209 -0
  55. fluxplot/stats/two_group.py +149 -0
  56. fluxplot/style.py +469 -0
  57. fluxplot/surface.py +487 -0
  58. fluxplot/surface3d.py +197 -0
  59. fluxplot/tagger.py +561 -0
  60. fluxplot/version.py +19 -0
  61. fluxplot-0.1.0.dist-info/METADATA +1199 -0
  62. fluxplot-0.1.0.dist-info/RECORD +65 -0
  63. fluxplot-0.1.0.dist-info/WHEEL +4 -0
  64. fluxplot-0.1.0.dist-info/licenses/LICENSE +21 -0
  65. 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)