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
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]
|