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
fluxplot/colorscale.py ADDED
@@ -0,0 +1,477 @@
1
+ """Portable colour scales — the exact value → colour law of every colour-mapped mark.
2
+
3
+ A heatmap, a hexmatrix, a filled contour or a colour-mapped scatter paints each element with
4
+ ``cmap(norm(value))``. matplotlib's rendering of that law is two tables and a handful of formulas:
5
+ a *lookup table* of ``N`` colours indexed by the normalised value, three extra colours for values
6
+ below, above and missing, and a *norm* that maps data to ``[0, 1]``. This module records both,
7
+ completely and portably (hex strings and plain numbers — no matplotlib name a consumer would have
8
+ to resolve), so anything that reads the manifest can recolour the plot exactly as matplotlib did,
9
+ change its limits or its map, and redraw its colour key, without running Python.
10
+
11
+ The record is the ``colorScales[]`` entry of the manifest::
12
+
13
+ {"id": "rates", # == the recipe's colour-control key; unique per figure
14
+ "kind": "continuous", # continuous | binned (a BoundaryNorm) | categorical
15
+ "colormap": {"name": "viridis", "source": "matplotlib", "N": 256, "lut": ["#440154ff", …],
16
+ "under": "#440154ff", "over": "#fde725ff", "bad": "#00000000", "discrete": false},
17
+ "norm": {"kind": "log", "vmin": 1.0, "vmax": 53.0, "clip": false, "base": 10, "extend": "neither"},
18
+ "mappables": ["rates.hexes"], # svg ids of every group this scale colours
19
+ "colorbars": ["colorbar.color"], # the colour keys drawing it
20
+ "label": "Synapses per hexbin",
21
+ "recolor": "live", # live | raster | regenerate
22
+ "editable": {"cmap": true, "limits": true, "normKinds": ["linear", "log", "power", "symlog"],
23
+ "center": false}}
24
+
25
+ **The lookup rule** (``Colormap.__call__``): a normalised ``x`` maps to ``lut[trunc(x * N)]``;
26
+ ``x == 1`` maps to the last entry; ``x < 0`` to ``under``, ``x * N >= N`` (``x > 1``) to ``over``,
27
+ NaN or masked to ``bad``. A ``boundary`` norm yields an *index* instead of a fraction: it is used
28
+ directly, ``-1`` meaning ``under`` and ``N`` meaning ``over``. :func:`apply` is the reference
29
+ implementation of the whole law — every consumer (Flux's ``colorscale.ts``) must agree with it
30
+ hex for hex, and ``tests/fixtures/colorscale_vectors.json`` carries the vectors both sides check.
31
+
32
+ The same module owns the colour controls a recipe carries (``recipe.params.__fluxplot__[id]``):
33
+ :func:`apply_override` turns an edit — a map name or a LUT, limits, a norm kind, ``extend`` —
34
+ into the matplotlib objects a helper draws with, and :func:`controls_state` writes the complete
35
+ current state back, so an editor always starts from the real values.
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import hashlib
40
+ import json
41
+
42
+ import numpy as np
43
+ from matplotlib import colors as mcolors
44
+ from matplotlib.colors import to_hex
45
+
46
+ #: matplotlib norm class → the portable kind name.
47
+ NORM_KINDS = {
48
+ "Normalize": "linear",
49
+ "LogNorm": "log",
50
+ "SymLogNorm": "symlog",
51
+ "PowerNorm": "power",
52
+ "TwoSlopeNorm": "twoslope",
53
+ "CenteredNorm": "centered",
54
+ "BoundaryNorm": "boundary",
55
+ "NoNorm": "none",
56
+ }
57
+ #: The kinds a consumer may switch a continuous scale to (each needs only limits, or one number).
58
+ SWITCHABLE_KINDS = ("linear", "log", "power", "symlog")
59
+ #: Above this many entries a lookup table is resampled (and flagged ``approximate``).
60
+ LUT_MAX = 1024
61
+
62
+ _HUGE = 1e250 # matplotlib's stand-in level for a filled contour's extend bands
63
+
64
+
65
+ def _hex(rgba) -> str:
66
+ return to_hex(rgba, keep_alpha=True)
67
+
68
+
69
+ def _num(v):
70
+ return None if v is None else float(v)
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # records
75
+ # ---------------------------------------------------------------------------
76
+ def colormap_source(cmap) -> str:
77
+ """Where a colormap comes from: ``matplotlib`` | ``fluxplot`` | ``crameri`` | ``tol`` |
78
+ ``cmasher`` | ``custom`` (an unnamed or unregistered map)."""
79
+ import matplotlib as mpl
80
+
81
+ from .colors import maps
82
+ name = getattr(cmap, "name", None) or ""
83
+ base = name[:-2] if name.endswith("_r") else name
84
+ if base in getattr(mpl.colormaps, "_builtin_cmaps", ()):
85
+ return "matplotlib"
86
+ try:
87
+ collection = maps.info(name).get("collection")
88
+ except Exception:
89
+ return "custom"
90
+ if collection == "mpl":
91
+ return "matplotlib"
92
+ if collection == "flexoki":
93
+ return "fluxplot" if base in maps._custom else "custom"
94
+ return collection or "custom"
95
+
96
+
97
+ def colormap_record(cmap) -> dict:
98
+ """The exact lookup table matplotlib uses, portable: ``{name, source, N, lut, under, over, bad,
99
+ discrete}`` (+ ``approximate: true`` when a >1024-entry table was resampled)."""
100
+ from .colors import DISCRETE_MAX
101
+ approximate = False
102
+ if cmap.N > LUT_MAX:
103
+ cmap, approximate = cmap.resampled(LUT_MAX), True
104
+ lut = [_hex(c) for c in cmap(np.arange(cmap.N))] # integer indexing reads the table itself
105
+ out = {
106
+ "name": cmap.name,
107
+ "source": colormap_source(cmap),
108
+ "N": int(cmap.N),
109
+ "lut": lut,
110
+ "under": _hex(cmap.get_under()),
111
+ "over": _hex(cmap.get_over()),
112
+ "bad": _hex(cmap.get_bad()),
113
+ "discrete": bool(isinstance(cmap, mcolors.ListedColormap) and cmap.N <= DISCRETE_MAX),
114
+ }
115
+ if approximate:
116
+ out["approximate"] = True
117
+ return out
118
+
119
+
120
+ def norm_record(norm, *, extend="neither") -> dict:
121
+ """``{kind, vmin, vmax, clip, extend, …}`` with the kind's own parameters (``vcenter``,
122
+ ``halfrange``, ``gamma``, ``linthresh``, ``linscale``, ``base``, ``boundaries``, ``ncolors``).
123
+ An unknown ``Normalize`` subclass is recorded as ``custom`` with its class name."""
124
+ kind = NORM_KINDS.get(type(norm).__name__)
125
+ out = {"kind": kind or "custom", "vmin": _num(norm.vmin), "vmax": _num(norm.vmax),
126
+ "clip": bool(norm.clip), "extend": extend}
127
+ if kind is None:
128
+ out["className"] = type(norm).__name__
129
+ if isinstance(norm, mcolors.TwoSlopeNorm):
130
+ out["vcenter"] = float(norm.vcenter)
131
+ elif isinstance(norm, mcolors.CenteredNorm):
132
+ out["vcenter"] = float(norm.vcenter)
133
+ out["halfrange"] = _num(norm.halfrange)
134
+ elif isinstance(norm, mcolors.PowerNorm):
135
+ out["gamma"] = float(norm.gamma)
136
+ elif isinstance(norm, mcolors.SymLogNorm):
137
+ scale = norm._scale
138
+ out["linthresh"] = float(scale.linthresh)
139
+ out["linscale"] = float(scale.linscale)
140
+ out["base"] = float(scale.base)
141
+ elif isinstance(norm, mcolors.LogNorm):
142
+ out["base"] = float(getattr(norm._scale, "base", 10.0))
143
+ elif isinstance(norm, mcolors.BoundaryNorm):
144
+ out["boundaries"] = [float(b) for b in norm.boundaries]
145
+ out["ncolors"] = int(norm.Ncmap)
146
+ out["extend"] = norm.extend
147
+ return out
148
+
149
+
150
+ def scale_record(scale_id, mappable, *, label=None, extend=None, kind=None, recolor="live") -> dict:
151
+ """One manifest ``colorScales[]`` entry for a matplotlib ``ScalarMappable``. ``mappables`` and
152
+ ``colorbars`` are filled in when the manifest is assembled (they are svg ids)."""
153
+ norm = mappable.norm
154
+ if extend is None:
155
+ cb = getattr(mappable, "colorbar", None)
156
+ extend = getattr(cb, "extend", None) or getattr(mappable, "extend", None) or "neither"
157
+ nr = norm_record(norm, extend=extend)
158
+ if kind is None:
159
+ kind = "binned" if nr["kind"] == "boundary" else "continuous"
160
+ switchable = kind == "continuous" and nr["kind"] in SWITCHABLE_KINDS + ("twoslope", "centered")
161
+ kinds = list(SWITCHABLE_KINDS) if switchable else []
162
+ if switchable and nr["kind"] not in kinds:
163
+ kinds.append(nr["kind"])
164
+ return {
165
+ "id": str(scale_id),
166
+ "kind": kind,
167
+ "colormap": colormap_record(mappable.get_cmap()),
168
+ "norm": nr,
169
+ "mappables": [],
170
+ "colorbars": [],
171
+ "label": label,
172
+ "recolor": recolor,
173
+ "editable": {
174
+ "cmap": True,
175
+ "limits": nr["kind"] not in ("boundary", "custom", "none"),
176
+ "normKinds": kinds,
177
+ "center": nr["kind"] in ("twoslope", "centered"),
178
+ },
179
+ }
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # the reference implementation of the law
184
+ # ---------------------------------------------------------------------------
185
+ def _log(values, base):
186
+ if base == 10:
187
+ return np.log10(values)
188
+ if base == 2:
189
+ return np.log2(values)
190
+ if base == np.e:
191
+ return np.log(values)
192
+ return np.log(values) / np.log(base)
193
+
194
+
195
+ def normalize(norm: dict, values) -> np.ma.MaskedArray:
196
+ """Port of each matplotlib norm's ``__call__`` for a ``norm`` record: floats in (about)
197
+ ``[0, 1]`` for the continuous kinds, colour *indices* for ``boundary`` / ``none``. Masked
198
+ entries are the ``bad`` values (missing data, non-positive on a log scale)."""
199
+ v = np.ma.masked_invalid(np.ma.asarray(values, dtype=float))
200
+ kind, vmin, vmax = norm["kind"], norm.get("vmin"), norm.get("vmax")
201
+ clip = bool(norm.get("clip"))
202
+ if kind in ("linear", "centered", "power", "log", "symlog"):
203
+ if vmin is None or vmax is None:
204
+ raise ValueError(f"colour scale: a {kind} norm needs vmin and vmax")
205
+ if vmin > vmax:
206
+ raise ValueError("colour scale: vmin must not exceed vmax")
207
+ if vmin == vmax:
208
+ return np.ma.zeros(v.shape) + np.ma.masked_array(np.zeros(v.shape), mask=np.ma.getmaskarray(v))
209
+ if clip:
210
+ v = np.ma.clip(v, vmin, vmax)
211
+ if kind in ("linear", "centered"):
212
+ return (v - vmin) / (vmax - vmin)
213
+ if kind == "power":
214
+ r = (v - vmin) / (vmax - vmin)
215
+ pos = np.ma.filled(r > 0, False)
216
+ r[pos] = np.power(r[pos], norm["gamma"])
217
+ return r
218
+ if kind == "log":
219
+ base = norm.get("base", 10.0)
220
+ t = np.ma.masked_invalid(_log(np.ma.filled(np.ma.masked_less_equal(v, 0), np.nan), base))
221
+ t_lo, t_hi = _log(np.array([vmin, vmax], dtype=float), base)
222
+ return (t - t_lo) / (t_hi - t_lo)
223
+ if kind == "symlog":
224
+ base, linthresh, linscale = norm.get("base", 10.0), norm["linthresh"], norm.get("linscale", 1.0)
225
+ adj = linscale / (1.0 - 1.0 / base)
226
+
227
+ def trf(a):
228
+ a = np.asarray(a, dtype=float)
229
+ abs_a = np.abs(a)
230
+ with np.errstate(divide="ignore", invalid="ignore"):
231
+ out = np.sign(a) * linthresh * (adj - np.log(linthresh) / np.log(base) + np.log(abs_a) / np.log(base))
232
+ inside = abs_a <= linthresh
233
+ out[inside] = a[inside] * adj
234
+ return out
235
+ t = np.ma.masked_array(trf(np.ma.filled(v, 0.0)), mask=np.ma.getmaskarray(v))
236
+ t_lo, t_hi = trf(np.array([vmin, vmax]))
237
+ return (t - t_lo) / (t_hi - t_lo)
238
+ if kind == "twoslope":
239
+ vcenter = norm["vcenter"]
240
+ if not vmin <= vcenter <= vmax:
241
+ raise ValueError("colour scale: vmin, vcenter, vmax must increase monotonically")
242
+ return np.ma.masked_array(np.interp(np.ma.filled(v, vmin), [vmin, vcenter, vmax], [0, 0.5, 1],
243
+ left=-np.inf, right=np.inf), mask=np.ma.getmaskarray(v))
244
+ if kind == "boundary":
245
+ b = np.asarray(norm["boundaries"], dtype=float)
246
+ ncolors, extend = int(norm["ncolors"]), norm.get("extend", "neither")
247
+ lo, hi = float(b[0]), float(b[-1])
248
+ offset = 1 if extend in ("min", "both") else 0
249
+ n_regions = len(b) - 1 + offset + (1 if extend in ("max", "both") else 0)
250
+ xx = np.ma.filled(v, hi + 1)
251
+ if clip:
252
+ xx = np.clip(xx, lo, hi)
253
+ max_col = ncolors - 1
254
+ else:
255
+ max_col = ncolors
256
+ iret = np.digitize(xx, b) - 1 + offset
257
+ if ncolors > n_regions:
258
+ if n_regions == 1:
259
+ iret[iret == 0] = (ncolors - 1) // 2
260
+ else:
261
+ iret = (ncolors - 1) / (n_regions - 1) * iret
262
+ iret = iret.astype(np.int16)
263
+ iret[xx < lo] = -1
264
+ iret[xx >= hi] = max_col
265
+ return np.ma.masked_array(iret, mask=np.ma.getmaskarray(v))
266
+ if kind == "none":
267
+ return v
268
+ raise ValueError(f"colour scale: cannot evaluate a {kind!r} norm")
269
+
270
+
271
+ def lookup(colormap: dict, x) -> list:
272
+ """Colour of each normalised value (or index) per the lookup rule, as ``#rrggbbaa``."""
273
+ lut, n = colormap["lut"], int(colormap["N"])
274
+ xa = np.ma.asarray(x)
275
+ # matplotlib masks NaN (and masked input) as bad; an infinity is merely far below / above
276
+ bad = np.ma.getmaskarray(xa) | np.isnan(np.ma.filled(xa.astype(float), 0.0))
277
+ data = np.ma.filled(xa, 0.0)
278
+ if np.asarray(data).dtype.kind == "f":
279
+ scaled = np.asarray(data, dtype=float) * n
280
+ scaled = np.where(scaled == n, n - 1, scaled)
281
+ under = scaled < 0
282
+ over = scaled >= n
283
+ with np.errstate(invalid="ignore"):
284
+ idx = np.trunc(np.where(np.isfinite(scaled), scaled, 0)).astype(int)
285
+ else:
286
+ idx = np.asarray(data, dtype=int)
287
+ under = idx < 0
288
+ over = idx >= n
289
+ idx = np.clip(idx, 0, n - 1)
290
+ out = []
291
+ for i, u, o, b in zip(idx.ravel(), under.ravel(), over.ravel(), bad.ravel()):
292
+ out.append(colormap["bad"] if b else colormap["under"] if u else colormap["over"] if o else lut[int(i)])
293
+ return out
294
+
295
+
296
+ def apply(record: dict, values) -> list:
297
+ """Reference implementation: the colour (``#rrggbbaa``) matplotlib paints each value with."""
298
+ return lookup(record["colormap"], normalize(record["norm"], values))
299
+
300
+
301
+ # ---------------------------------------------------------------------------
302
+ # the recipe's colour controls
303
+ # ---------------------------------------------------------------------------
304
+ def _lut_name(lut) -> str:
305
+ return "custom:" + hashlib.sha1(json.dumps(list(lut)).encode()).hexdigest()[:10]
306
+
307
+
308
+ def colormap_from_spec(spec, key, resolve):
309
+ """A ``Colormap`` from a control's ``cmap``: a name (through ``resolve``), a ``Colormap``, or a
310
+ ``{"lut": [...], "under"?, "over"?, "bad"?}`` table."""
311
+ if isinstance(spec, mcolors.Colormap):
312
+ return spec
313
+ if isinstance(spec, dict):
314
+ lut = spec.get("lut")
315
+ if not lut:
316
+ raise ValueError(f"colour control {key!r}: a colormap table needs a non-empty 'lut'")
317
+ cm = mcolors.ListedColormap(list(lut), name=spec.get("name") or _lut_name(lut))
318
+ if spec.get("under"):
319
+ cm.set_under(spec["under"])
320
+ if spec.get("over"):
321
+ cm.set_over(spec["over"])
322
+ if spec.get("bad"):
323
+ cm.set_bad(spec["bad"])
324
+ return cm
325
+ if isinstance(spec, str):
326
+ try:
327
+ return resolve(spec)
328
+ except (ValueError, KeyError):
329
+ raise ValueError(
330
+ f"colour control {key!r}: unknown colormap {spec!r}; use a matplotlib name, "
331
+ "a fluxplot map (fp.colors.maps.collections()), a registered custom map or a LUT"
332
+ ) from None
333
+ raise ValueError(f"colour control {key!r}: cmap must be a name or a {{'lut': [...]}} table")
334
+
335
+
336
+ def _same_colormap(a, b) -> bool:
337
+ if a is None or b is None:
338
+ return False
339
+ if a is b:
340
+ return True
341
+ if a.N != b.N:
342
+ return False
343
+ return (np.array_equal(a(np.arange(a.N)), b(np.arange(b.N)))
344
+ and np.array_equal(a.get_under(), b.get_under()) and np.array_equal(a.get_over(), b.get_over())
345
+ and np.array_equal(a.get_bad(), b.get_bad()))
346
+
347
+
348
+ def _as_colormap(spec, resolve):
349
+ if spec is None or isinstance(spec, mcolors.Colormap):
350
+ return spec
351
+ if isinstance(spec, str):
352
+ try:
353
+ return resolve(spec)
354
+ except (ValueError, KeyError):
355
+ return None
356
+ return None
357
+
358
+
359
+ def make_norm(spec: dict, vmin, vmax, key) -> mcolors.Normalize:
360
+ """A matplotlib norm from a control's ``norm`` record and the carried limits, validated."""
361
+ kind = spec.get("kind", "linear")
362
+ if kind == "log":
363
+ if vmin is not None and vmin <= 0:
364
+ raise ValueError(f"colour control {key!r}: a log norm needs vmin > 0 (got {vmin!r})")
365
+ return mcolors.LogNorm(vmin=vmin, vmax=vmax)
366
+ if kind == "symlog":
367
+ linthresh = spec.get("linthresh", 1.0)
368
+ if linthresh <= 0:
369
+ raise ValueError(f"colour control {key!r}: symlog linthresh must be positive")
370
+ return mcolors.SymLogNorm(linthresh, linscale=spec.get("linscale", 1.0), vmin=vmin, vmax=vmax,
371
+ base=spec.get("base", 10))
372
+ if kind == "power":
373
+ return mcolors.PowerNorm(spec.get("gamma", 1.0), vmin=vmin, vmax=vmax)
374
+ if kind == "twoslope":
375
+ vcenter = spec.get("vcenter", 0.0)
376
+ if vmin is not None and not vmin < vcenter:
377
+ raise ValueError(f"colour control {key!r}: twoslope needs vmin < vcenter (vmin={vmin!r}, vcenter={vcenter!r})")
378
+ if vmax is not None and not vcenter < vmax:
379
+ raise ValueError(f"colour control {key!r}: twoslope needs vcenter < vmax (vcenter={vcenter!r}, vmax={vmax!r})")
380
+ return mcolors.TwoSlopeNorm(vcenter=vcenter, vmin=vmin, vmax=vmax)
381
+ if kind == "centered":
382
+ halfrange = spec.get("halfrange")
383
+ if halfrange is None and vmin is not None and vmax is not None:
384
+ halfrange = max(abs(spec.get("vcenter", 0.0) - vmin), abs(vmax - spec.get("vcenter", 0.0)))
385
+ return mcolors.CenteredNorm(vcenter=spec.get("vcenter", 0.0), halfrange=halfrange)
386
+ if kind == "linear":
387
+ return mcolors.Normalize(vmin=vmin, vmax=vmax)
388
+ raise ValueError(f"colour control {key!r}: cannot build a {kind!r} norm; "
389
+ f"use one of {', '.join(SWITCHABLE_KINDS + ('twoslope', 'centered'))}")
390
+
391
+
392
+ def _norm_kind_of(norm) -> str | None:
393
+ if isinstance(norm, str):
394
+ return {"linear": "linear", "log": "log", "symlog": "symlog", "logit": None}.get(norm)
395
+ if isinstance(norm, mcolors.Normalize):
396
+ return NORM_KINDS.get(type(norm).__name__)
397
+ return "linear" if norm is None else None
398
+
399
+
400
+ def _norm_params_equal(spec: dict, norm) -> bool:
401
+ """True when the record's parameters match the script's norm object (limits aside)."""
402
+ if not isinstance(norm, mcolors.Normalize):
403
+ return spec.get("kind", "linear") == _norm_kind_of(norm)
404
+ rec = norm_record(norm)
405
+ if rec["kind"] != spec.get("kind", "linear"):
406
+ return False
407
+ for k in ("vcenter", "gamma", "linthresh", "linscale", "base"):
408
+ if k in spec and not np.isclose(float(spec[k]), float(rec.get(k, np.nan))):
409
+ return False
410
+ return True
411
+
412
+
413
+ def apply_override(kwargs: dict, override: dict, key: str, *, resolve) -> None:
414
+ """Apply one recipe colour control (v1 ``{cmap, vmin, vmax}`` or v2, see the module doc) to a
415
+ helper's colour keywords, in place, keeping the script's own objects wherever the control
416
+ merely restates them.
417
+
418
+ ``kwargs['cmap']`` becomes a ``Colormap`` when it changes; ``kwargs['norm']`` a norm object
419
+ when the kind or a parameter changes; ``vmin`` / ``vmax`` are set outright (the caller moves
420
+ them onto the norm); ``kwargs['_extend']`` carries ``extend`` for the colour key.
421
+ """
422
+ wanted = override.get("cmap")
423
+ if wanted is not None:
424
+ script = _as_colormap(kwargs.get("cmap"), resolve)
425
+ target = colormap_from_spec(wanted, key, resolve)
426
+ if not (_same_colormap(script, target) or (isinstance(wanted, str) and isinstance(kwargs.get("cmap"), str)
427
+ and wanted == kwargs["cmap"])):
428
+ kwargs["cmap"] = target
429
+ if override.get("reversed"):
430
+ cm = _as_colormap(kwargs.get("cmap"), resolve)
431
+ if cm is None:
432
+ from ._fieldmap import resolve_colormap
433
+ cm = resolve_colormap(None)
434
+ kwargs["cmap"] = cm.reversed()
435
+ for option in ("vmin", "vmax"):
436
+ if option in override:
437
+ kwargs[option] = override[option]
438
+ spec = override.get("norm")
439
+ if isinstance(spec, dict) and spec:
440
+ current = kwargs.get("norm")
441
+ if not _norm_params_equal(spec, current):
442
+ vmin = kwargs.pop("vmin", None) if "vmin" in kwargs else getattr(current, "vmin", None)
443
+ vmax = kwargs.pop("vmax", None) if "vmax" in kwargs else getattr(current, "vmax", None)
444
+ kwargs["norm"] = make_norm(spec, vmin, vmax, key)
445
+ elif isinstance(current, mcolors.Normalize) and spec.get("kind") == "log":
446
+ vmin = kwargs.get("vmin", current.vmin)
447
+ if vmin is not None and vmin <= 0:
448
+ raise ValueError(f"colour control {key!r}: a log norm needs vmin > 0 (got {vmin!r})")
449
+ if "extend" in override:
450
+ if override["extend"] not in ("neither", "min", "max", "both"):
451
+ raise ValueError(f"colour control {key!r}: extend must be neither, min, max or both")
452
+ kwargs["_extend"] = override["extend"]
453
+
454
+
455
+ def cmap_spec(cmap, resolve) -> object:
456
+ """How a recipe names this map: its name when ``resolve`` rebuilds the identical map from it,
457
+ else the portable ``{"lut", "under", "over", "bad"}`` table."""
458
+ rebuilt = _as_colormap(getattr(cmap, "name", None), resolve)
459
+ if rebuilt is not None and _same_colormap(rebuilt, cmap):
460
+ return cmap.name
461
+ rec = colormap_record(cmap)
462
+ return {k: rec[k] for k in ("lut", "under", "over", "bad")}
463
+
464
+
465
+ def controls_state(field: dict) -> dict:
466
+ """The complete v2 colour control for a captured field: what an editor starts from and what a
467
+ rerun replays."""
468
+ nr = field["normalization"] # its kind is the matplotlib class name (kept as an alias)
469
+ norm = {"kind": NORM_KINDS.get(nr["kind"], "custom")}
470
+ for k in ("vcenter", "gamma", "linthresh", "linscale"):
471
+ if nr.get(k) is not None:
472
+ norm[k] = nr[k]
473
+ out = {"cmap": field.get("cmapSpec", field["cmap"]), "vmin": nr.get("vmin"), "vmax": nr.get("vmax"),
474
+ "norm": norm}
475
+ if field.get("extend"):
476
+ out["extend"] = field["extend"]
477
+ return out
fluxplot/data.py ADDED
@@ -0,0 +1,178 @@
1
+ """Exact public artist data adapters shared by wrappers and save-time capture."""
2
+ from __future__ import annotations
3
+ import numpy as np
4
+
5
+
6
+ def values(seq):
7
+ """Numeric data with null gaps; preserve length and original observation indices."""
8
+ if seq is None:
9
+ return None
10
+ arr = np.ma.asarray(seq, dtype=float).filled(np.nan)
11
+ return [float(v) if np.isfinite(v) else None for v in np.atleast_1d(arr).reshape(-1)]
12
+
13
+
14
+ def converted(ax, x, y):
15
+ return values(ax.convert_xunits(x)), values(ax.convert_yunits(y))
16
+
17
+
18
+ def artist_xy(artist):
19
+ from matplotlib.collections import PathCollection
20
+ from matplotlib.lines import Line2D
21
+ # 3D collections sort by depth; their offsets are not in source order.
22
+ if getattr(getattr(artist, 'axes', None), 'name', None) == '3d':
23
+ return None, None
24
+ if isinstance(artist, Line2D):
25
+ return values(artist.get_xdata(orig=False)), values(artist.get_ydata(orig=False))
26
+ if isinstance(artist, PathCollection):
27
+ off = np.ma.asarray(artist.get_offsets(), dtype=float)
28
+ if off.ndim == 2 and off.shape[1] == 2:
29
+ return values(off[:, 0]), values(off[:, 1])
30
+ return None, None
31
+
32
+
33
+ def bar_data(patches, orientation='vertical', axes=None):
34
+ """A bar group's data geometry: per bar its ``center`` along the category axis, ``width``
35
+ (thickness), ``baseline`` and ``length`` in data units, and a stable ``key`` — the category
36
+ label under the bar when the axis is categorical (or has a tick labelled there), else the
37
+ centre's value as text — so two versions of the plot can be tweened bar by bar."""
38
+ horizontal = orientation == 'horizontal'
39
+ centers = [p.get_y() + p.get_height()/2 if horizontal else p.get_x() + p.get_width()/2 for p in patches]
40
+ widths = [p.get_height() if horizontal else p.get_width() for p in patches]
41
+ lengths = [p.get_width() if horizontal else p.get_height() for p in patches]
42
+ bases = [p.get_x() if horizontal else p.get_y() for p in patches]
43
+ ends = np.asarray(bases) + np.asarray(lengths)
44
+ x, y = (ends, centers) if horizontal else (centers, ends)
45
+ meta = {'orientation': orientation, 'baseline': values(bases), 'length': values(lengths),
46
+ 'center': values(centers), 'width': values(widths)}
47
+ axes = axes if axes is not None else getattr(patches[0], 'axes', None) if patches else None
48
+ if axes is not None:
49
+ meta['keys'] = category_keys(axes.yaxis if horizontal else axes.xaxis, centers)
50
+ return values(x), values(y), meta
51
+
52
+
53
+ def category_keys(mpl_axis, positions):
54
+ """A stable text key for each position on ``mpl_axis``: the category name (a categorical
55
+ axis), else the tick label sitting there, else the position itself as text."""
56
+ from .capture import tick_kinds
57
+ labels = {}
58
+ units = getattr(mpl_axis, 'units', None)
59
+ mapping = getattr(units, '_mapping', None) # matplotlib.category.UnitData
60
+ if isinstance(mapping, dict):
61
+ labels.update({float(v): str(k) for k, v in mapping.items()})
62
+ locator, formatter = tick_kinds(mpl_axis)
63
+ if locator in ('fixed', 'category') or formatter in ('fixed', 'category'): # labels the caller set, not a number's format
64
+ for loc, txt in zip(mpl_axis.get_ticklocs(), mpl_axis.get_ticklabels()):
65
+ if txt.get_text() and float(loc) not in labels:
66
+ labels[float(loc)] = txt.get_text()
67
+ out = []
68
+ for pos in positions:
69
+ hit = next((lbl for loc, lbl in labels.items() if abs(loc - float(pos)) <= 1e-9), None)
70
+ out.append(hit if hit is not None else f'{float(pos):g}')
71
+ return out
72
+
73
+
74
+ def refresh(mark):
75
+ """Refresh the save snapshot only; registration keeps its stable identity."""
76
+ if not mark.artists:
77
+ return
78
+ art = mark.artists[0]
79
+ from .fields import capture_mark
80
+ capture_mark(mark)
81
+ if mark.data.get('field_config', {}).get('kind') == 'scatter' and hasattr(art, 'get_array'):
82
+ mark.data['c'] = values(art.get_array()) # the colour-mapped values, as drawn
83
+ if mark.live_data:
84
+ if mark.role == 'bar':
85
+ mark.x, mark.y, meta = bar_data(mark.artists, mark.data.get('bar', {}).get('orientation', 'vertical'),
86
+ axes=mark.axes)
87
+ mark.data['bar'] = meta
88
+ else:
89
+ x, y = artist_xy(art)
90
+ if x is not None and y is not None:
91
+ mark.x, mark.y = x, y
92
+ elif getattr(getattr(art, 'axes', None), 'name', None) == '3d':
93
+ mark.x = mark.y = None
94
+ if mark.x is not None:
95
+ mark.x, mark.y = values(mark.x), values(mark.y)
96
+ from matplotlib.text import Text
97
+ if isinstance(art, Text):
98
+ mark.data['text'] = art.get_text()
99
+ if mark.role == 'area' and hasattr(art, 'get_paths') and 'x' not in (mark.data.get('band') or {}):
100
+ # a promoted fill_between: only its polygon is known (fp.area / fp.band record their inputs)
101
+ mark.data['band'] = {'paths': [[values(v) for v in p.vertices] for p in art.get_paths()]}
102
+ if mark.series is not None:
103
+ mark.data['color'] = primary_paint(mark)
104
+
105
+
106
+ def primary_paint(mark):
107
+ """The one colour a series mark is painted with (``{hex, alpha, token?, palette?}``), or
108
+ ``{"hex": "varies"}`` when its elements differ (a colour-mapped collection). A Line2D's line
109
+ colour, a collection's or a bar's face colour; ``token`` is the exact palette token
110
+ (``flexoki.blue-600``) and ``palette`` the position in the active cycle, when either holds."""
111
+ from matplotlib.collections import Collection
112
+ from matplotlib.colors import to_hex, to_rgba
113
+ from matplotlib.lines import Line2D
114
+ from matplotlib.patches import Patch
115
+ from .colors import palette_of, token_of
116
+ arts = mark.artists
117
+ if not arts:
118
+ return None
119
+ rgba = None
120
+ art = arts[0]
121
+ try:
122
+ if isinstance(art, Line2D):
123
+ rgba = to_rgba(art.get_color())
124
+ elif isinstance(art, Collection):
125
+ faces = art.get_facecolor()
126
+ if getattr(art, 'get_array', None) is not None and art.get_array() is not None:
127
+ return {'hex': 'varies'}
128
+ if len(faces) == 0:
129
+ edges = art.get_edgecolor()
130
+ if len(edges) == 0:
131
+ return None
132
+ faces = edges
133
+ first = tuple(faces[0])
134
+ if any(tuple(f) != first for f in faces):
135
+ return {'hex': 'varies'}
136
+ rgba = first
137
+ elif isinstance(art, Patch):
138
+ first = to_rgba(art.get_facecolor())
139
+ if any(to_rgba(a.get_facecolor()) != first for a in arts if isinstance(a, Patch)):
140
+ return {'hex': 'varies'}
141
+ rgba = first
142
+ except (ValueError, TypeError, AttributeError):
143
+ return None
144
+ if rgba is None:
145
+ return None
146
+ out = {'hex': to_hex(rgba, keep_alpha=False), 'alpha': round(float(rgba[3]), 6)}
147
+ token = token_of(rgba)
148
+ if token:
149
+ out['token'] = token
150
+ palette = palette_of(rgba)
151
+ if palette:
152
+ out['palette'] = palette
153
+ return out
154
+
155
+
156
+ def point_indices(mark):
157
+ """Eligible source indices, before the exact SVG count guard."""
158
+ if mark.x is None or mark.y is None:
159
+ return []
160
+ indices = np.arange(min(len(mark.x), len(mark.y)))
161
+ art = mark.artists[0]
162
+ every = getattr(art, 'get_markevery', lambda: None)()
163
+ if every is not None:
164
+ if isinstance(every, int):
165
+ indices = indices[::every]
166
+ elif isinstance(every, slice):
167
+ indices = indices[every]
168
+ elif isinstance(every, tuple) and len(every) == 2 and all(isinstance(v, int) for v in every):
169
+ indices = indices[every[0]::every[1]]
170
+ elif isinstance(every, (list, np.ndarray)):
171
+ indices = indices[every]
172
+ else:
173
+ return [] # display-distance subsampling has no stable source-index contract
174
+ subset = mark.data.get('point_subset')
175
+ if subset is not None: # this mark owns a subset of the collection's points (a hue level)
176
+ wanted = set(int(i) for i in subset)
177
+ indices = [i for i in indices if int(i) in wanted]
178
+ return [int(i) for i in indices if mark.x[i] is not None and mark.y[i] is not None]