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/fields.py ADDED
@@ -0,0 +1,611 @@
1
+ """Matrix, contour and color-key helpers; the returned objects are Matplotlib artists.
2
+
3
+ Raw cell values are opt-in. Geometry, masks, exact normalization and level identity
4
+ are always recorded, while large fields use the normal bounded raster pipeline.
5
+ """
6
+ from __future__ import annotations
7
+ import numpy as np
8
+ from . import colorscale, ids, tagger
9
+ from ._fieldmap import resolve_colormap
10
+ from .descriptors import Mark, GuideTag
11
+ from .data import values
12
+
13
+
14
+ #: ``__fluxplot__`` entries that are not colour scales: the theme, the palette, per-series colours.
15
+ RESERVED_CONTROL_KEYS = frozenset({"theme", "palette", "series"})
16
+
17
+
18
+ def control_key(ax, series, key=None):
19
+ """``(key, legacy_key)`` naming a colour-mapped series' entry in ``recipe.params.__fluxplot__``.
20
+
21
+ The key is what Flux edits, so it has to survive the figure being rearranged. In order:
22
+
23
+ 1. the explicit ``key``;
24
+ 2. the bare series root (``rates``) when no other colour-controlled series of the figure has
25
+ claimed it yet — the common case, and independent of where the axes sits;
26
+ 3. ``panel.<slug>.<root>`` when the axes was named with :func:`fluxplot.panel`;
27
+ 4. ``axes.<n>.<root>`` with ``n`` the axes' 1-based position among the figure's axes.
28
+
29
+ ``legacy_key`` is what rule 3/4 alone would have produced (the rule before 0.3.1); overrides
30
+ saved under it by an older Flux still apply. Claimed keys are remembered per figure in the
31
+ registry so two same-named series never share a key.
32
+ """
33
+ from . import tagger
34
+ from .panels import all_axes
35
+ root = ids.series_root(series)
36
+ panel = getattr(ax, '_fluxplot_panel_name', None)
37
+ positional = ('panel.' + ids.slugify(panel) if panel
38
+ else 'axes.' + str(all_axes(ax.figure).index(ax) + 1)) + '.' + root
39
+ claimed = tagger.registry_for(ax.figure)._color_keys
40
+ if key is not None:
41
+ chosen = str(key)
42
+ elif root not in claimed and root not in RESERVED_CONTROL_KEYS:
43
+ chosen = root
44
+ else:
45
+ chosen = positional
46
+ n = 2
47
+ while chosen in claimed: # the same series twice on one axes: deterministic, never silent
48
+ chosen = f'{positional}-{n}'
49
+ n += 1
50
+ claimed.add(chosen)
51
+ return chosen, positional
52
+
53
+
54
+ # ---------------------------------------------------------------------------------------------
55
+ # shared scales (A4): one colour scale for several panels
56
+ # ---------------------------------------------------------------------------------------------
57
+ class SharedScale:
58
+ """A colour scale declared once per figure (:func:`color_scale`) and joined by any number of
59
+ colour-mapped marks (``scale="name"``): they share its map and norm, its limits default to the
60
+ union of every member's finite values (resolved at save, before layout), and one
61
+ :func:`colorbar` can draw it for all of them."""
62
+
63
+ def __init__(self, name, *, cmap=None, norm="linear", vmin=None, vmax=None, center=None, robust=False):
64
+ from matplotlib import colors as mcolors
65
+ if norm not in ("linear", "log", "sqrt", "symlog") and not isinstance(norm, mcolors.Normalize):
66
+ raise ValueError(f"color_scale: norm must be 'linear', 'log', 'sqrt', 'symlog' or a Normalize; got {norm!r}")
67
+ if center is not None and norm != "linear":
68
+ raise ValueError("color_scale: center= needs a linear norm")
69
+ self.name, self.cmap, self.norm = str(name), cmap, norm
70
+ self.vmin, self.vmax, self.center, self.robust = vmin, vmax, center, robust
71
+ self.members = [] # (artist, values)
72
+ self.resolved = False
73
+
74
+ def make_norm(self, key):
75
+ from copy import copy
76
+ from matplotlib import colors as mcolors
77
+ if isinstance(self.norm, mcolors.Normalize):
78
+ return copy(self.norm)
79
+ if self.center is not None:
80
+ return colorscale.make_norm({"kind": "twoslope", "vcenter": self.center}, self.vmin, self.vmax, key)
81
+ kind = {"linear": "linear", "log": "log", "sqrt": "power", "symlog": "symlog"}[self.norm]
82
+ spec = {"kind": kind, "gamma": 0.5} if kind == "power" else {"kind": kind}
83
+ return colorscale.make_norm(spec, self.vmin, self.vmax, key)
84
+
85
+ def limits(self):
86
+ """The shared limits: the declaration's, else the union of every member's finite values."""
87
+ vals = [np.asarray(v, dtype=float).ravel() for _a, v in self.members if v is not None]
88
+ finite = np.concatenate([v[np.isfinite(v)] for v in vals]) if vals else np.array([])
89
+ if self.norm == "log":
90
+ finite = finite[finite > 0]
91
+ lo, hi = self.vmin, self.vmax
92
+ if finite.size:
93
+ if self.robust:
94
+ lo_p, hi_p = (2.0, 98.0) if self.robust is True else self.robust
95
+ auto_lo, auto_hi = (float(q) for q in np.percentile(finite, [lo_p, hi_p]))
96
+ else:
97
+ auto_lo, auto_hi = float(finite.min()), float(finite.max())
98
+ if self.center is not None:
99
+ half = max(abs(auto_lo - self.center), abs(auto_hi - self.center)) or 1.0
100
+ auto_lo, auto_hi = self.center - half, self.center + half
101
+ lo = auto_lo if lo is None else lo
102
+ hi = auto_hi if hi is None else hi
103
+ return lo, hi
104
+
105
+
106
+ def color_scale(name, *, cmap=None, norm="linear", vmin=None, vmax=None, center=None, robust=False, fig=None):
107
+ """Declare a colour scale shared across panels: ``fp.color_scale("corr", cmap="RdBu_r", center=0)``.
108
+
109
+ Every colour helper joins it with ``scale="corr"`` (its recipe key becomes ``corr``, its map
110
+ and norm come from here, its limits default to the union of all members' values), and
111
+ ``fp.colorbar(scale="corr", ax=[...])`` draws one key. The manifest carries one
112
+ ``colorScales`` entry listing every member, so a Flux edit recolours all panels at once.
113
+ """
114
+ import matplotlib.pyplot as plt
115
+ fig = fig if fig is not None else plt.gcf()
116
+ decl = SharedScale(name, cmap=cmap, norm=norm, vmin=vmin, vmax=vmax, center=center, robust=robust)
117
+ tagger.registry_for(fig)._scales[decl.name] = decl
118
+ return decl
119
+
120
+
121
+ def shared_scale(fig, name) -> SharedScale:
122
+ scales = tagger.registry_for(fig)._scales
123
+ if name not in scales:
124
+ raise ValueError(f"colour scale {name!r} is not declared on this figure; call fp.color_scale({name!r}, ...) first")
125
+ return scales[name]
126
+
127
+
128
+ def join_scale(ax, name, artist, values=None):
129
+ """Enrol a drawn mappable in a shared scale (helpers call this after drawing)."""
130
+ decl = shared_scale(ax.figure, name)
131
+ if values is None and hasattr(artist, "get_array"):
132
+ values = artist.get_array()
133
+ decl.members.append((artist, None if values is None else np.ma.filled(np.ma.asarray(values, dtype=float), np.nan)))
134
+
135
+
136
+ def resolve_scales(fig):
137
+ """Give every member of every shared scale the scale's limits — the union of their values — by
138
+ setting each member's own norm. Runs at the top of ``fp.save``, before layout."""
139
+ for decl in tagger.registry_for(fig)._scales.values():
140
+ lo, hi = decl.limits()
141
+ for artist, _values in decl.members:
142
+ norm = artist.norm
143
+ if lo is not None:
144
+ norm.vmin = lo
145
+ if hi is not None:
146
+ norm.vmax = hi
147
+ if hasattr(artist, "changed"):
148
+ artist.changed()
149
+ decl.resolved = True
150
+
151
+
152
+ # ---------------------------------------------------------------------------------------------
153
+ # value × confidence (B6): a second, alpha channel on a colour-mapped mark
154
+ # ---------------------------------------------------------------------------------------------
155
+ ALPHA_NORMS = ("linear", "log")
156
+
157
+
158
+ def alpha_channel(values, *, alpha_range=(0.25, 1.0), alpha_norm="linear", source="values"):
159
+ """Per-element alpha from a confidence-like variable: ``(alphas, record)``.
160
+
161
+ ``values`` map linearly (or by log10) from their finite min → ``alpha_range[0]`` to their
162
+ max → ``alpha_range[1]``; a missing value takes the low alpha. ``record`` is what the
163
+ manifest's ``colorScales[].alpha`` carries: ``{source, range, norm: {kind, vmin, vmax}}``, so
164
+ a consumer can recompute every element's opacity from its ``data-alpha-value``.
165
+ """
166
+ if alpha_norm not in ALPHA_NORMS:
167
+ raise ValueError(f"alpha_norm must be one of {ALPHA_NORMS}, got {alpha_norm!r}")
168
+ a0, a1 = (float(v) for v in alpha_range)
169
+ if not (0.0 <= a0 <= 1.0 and 0.0 <= a1 <= 1.0):
170
+ raise ValueError(f"alpha_range must lie within [0, 1], got {alpha_range!r}")
171
+ v = np.ma.filled(np.ma.asarray(values, dtype=float), np.nan).ravel()
172
+ finite = np.isfinite(v)
173
+ if alpha_norm == "log":
174
+ finite &= v > 0
175
+ if not finite.any():
176
+ return np.full(v.size, a1), {"source": source, "range": [a0, a1], "norm": {"kind": alpha_norm, "vmin": None, "vmax": None}}
177
+ lo, hi = float(v[finite].min()), float(v[finite].max())
178
+ if hi > lo:
179
+ if alpha_norm == "log":
180
+ t = (np.log10(np.where(finite, v, lo)) - np.log10(lo)) / (np.log10(hi) - np.log10(lo))
181
+ else:
182
+ t = (np.where(finite, v, lo) - lo) / (hi - lo)
183
+ else:
184
+ t = np.ones(v.size)
185
+ t = np.clip(np.where(finite, t, 0.0), 0.0, 1.0)
186
+ alphas = a0 + t * (a1 - a0)
187
+ return alphas, {"source": str(source), "range": [a0, a1], "norm": {"kind": alpha_norm, "vmin": lo, "vmax": hi}}
188
+
189
+
190
+ def apply_alpha(artist, mark_data, values, *, alpha_range, alpha_norm, source, shape=None):
191
+ """Set an artist's per-element alpha from ``values`` and record the channel on the mark."""
192
+ alphas, record = alpha_channel(values, alpha_range=alpha_range, alpha_norm=alpha_norm, source=source)
193
+ artist.set_alpha(alphas.reshape(shape) if shape is not None else alphas)
194
+ mark_data["alpha_channel"] = record
195
+ mark_data["alpha_values"] = values_list(values)
196
+ return alphas
197
+
198
+
199
+ def values_list(values):
200
+ v = np.ma.filled(np.ma.asarray(values, dtype=float), np.nan).ravel()
201
+ return [None if not np.isfinite(x) else float(x) for x in v]
202
+
203
+
204
+ def _options(ax, series, key, kwargs, *, resolve=None, scale=None):
205
+ """Apply the recipe's colour controls for one colour-mapped series; return its control key.
206
+
207
+ ``kwargs`` are the colour keywords the helper is about to pass to matplotlib (``cmap``,
208
+ ``vmin``, ``vmax``, ``norm``). Flux edits them through ``recipe.params.__fluxplot__[key]``
209
+ (see :func:`control_key`). An override is applied only where it differs from what the script
210
+ itself asked for: the controls are written back on every save, so replaying them must
211
+ reproduce the script's own objects (a ``ListedColormap`` has no resolvable name) rather than
212
+ fail on them. A colormap override that resolves to nothing raises, naming the key, instead of
213
+ letting matplotlib fail later on an unnamed map. ``resolve`` turns a colormap name into a
214
+ ``Colormap`` (default: :func:`fluxplot._fieldmap.resolve_colormap`); helpers with their own
215
+ name spaces (the hexmatrix's single-colour ramps) pass theirs.
216
+ """
217
+ from ._fieldmap import resolve_colormap
218
+ from .recipe import params
219
+ resolve = resolve or resolve_colormap
220
+ if scale is not None:
221
+ # a shared scale: its declaration is the source of map, norm and limits; the key is its name
222
+ decl = shared_scale(ax.figure, scale)
223
+ key = decl.name
224
+ tagger.registry_for(ax.figure)._color_keys.add(key)
225
+ legacy = key
226
+ if decl.cmap is not None:
227
+ kwargs['cmap'] = decl.cmap
228
+ kwargs['norm'] = decl.make_norm(key)
229
+ kwargs.pop('vmin', None)
230
+ kwargs.pop('vmax', None)
231
+ else:
232
+ key, legacy = control_key(ax, series, key)
233
+ controls = params().get('__fluxplot__') or {}
234
+ overrides = controls.get(key) if key not in RESERVED_CONTROL_KEYS else None
235
+ if overrides is None and legacy != key:
236
+ overrides = controls.get(legacy)
237
+ overrides = dict(overrides or {})
238
+ if overrides:
239
+ colorscale.apply_override(kwargs, overrides, key, resolve=resolve)
240
+ if scale is not None:
241
+ # an edited limit pins the shared scale: every member takes it instead of the union
242
+ norm_over = overrides.get('norm') if isinstance(overrides.get('norm'), dict) else {}
243
+ for option in ('vmin', 'vmax'):
244
+ edited = overrides.get(option, norm_over.get(option))
245
+ if edited is not None:
246
+ setattr(decl, option, float(edited))
247
+ # A caller's Normalize object may carry a nonlinear scale. Change its limits
248
+ # without replacing the scale or mutating the caller-owned instance.
249
+ if kwargs.get('norm') is not None and not isinstance(kwargs['norm'], str):
250
+ from copy import copy
251
+ norm = copy(kwargs['norm'])
252
+ for option in ('vmin', 'vmax'):
253
+ if option in kwargs:
254
+ setattr(norm, option, kwargs.pop(option))
255
+ kwargs['norm'] = norm
256
+ return key
257
+
258
+
259
+ def normalization(artist):
260
+ """The norm as ``field.normalization`` records it: ``kind`` is the matplotlib class name (kept
261
+ as an alias of the portable ``colorScales[].norm.kind``), the parameters are the record's."""
262
+ record = colorscale.norm_record(artist.norm)
263
+ out = {'kind': type(artist.norm).__name__, 'vmin': record['vmin'], 'vmax': record['vmax'], 'clip': record['clip']}
264
+ for attr in ('vcenter', 'halfrange', 'gamma', 'linthresh', 'linscale', 'base', 'boundaries'):
265
+ if record.get(attr) is not None:
266
+ out[attr] = record[attr]
267
+ return out
268
+
269
+
270
+ def _capture(artist, config, resolve=None):
271
+ out = dict(config)
272
+ out['normalization'] = normalization(artist)
273
+ out['cmap'] = artist.get_cmap().name
274
+ out['missingColor'] = artist.get_cmap().get_bad().tolist()
275
+ out['underColor'] = artist.get_cmap().get_under().tolist()
276
+ out['overColor'] = artist.get_cmap().get_over().tolist()
277
+ # the portable colour scale (colorscale.py): the recipe names the map the way it can rebuild
278
+ # it, the manifest's colorScales[] entry carries the exact LUT, and the field points at it
279
+ out['cmapSpec'] = colorscale.cmap_spec(artist.get_cmap(), resolve or resolve_colormap)
280
+ out['colorScale'] = out['controlKey']
281
+ if not out.get('extend'):
282
+ cb = getattr(artist, 'colorbar', None)
283
+ out['extend'] = getattr(cb, 'extend', None) or getattr(artist, 'extend', None) or 'neither'
284
+ # imshow / pcolormesh can be edited after helper construction.
285
+ if out['kind'] == 'heatmap':
286
+ arr = np.ma.masked_invalid(np.ma.asarray(artist.get_array(), dtype=float))
287
+ shape = out['shape']
288
+ arr = arr.reshape(shape)
289
+ out['maskedIndices'] = np.flatnonzero(np.ma.getmaskarray(arr)).tolist()
290
+ if out.pop('includeValues', False):
291
+ out['values'] = [values(row) for row in arr]
292
+ if hasattr(artist, 'get_extent'):
293
+ out['extent'] = [float(x) for x in artist.get_extent()]
294
+ out['origin'] = artist.origin
295
+ else:
296
+ coords = artist.get_coordinates()
297
+ xx, yy = coords[:, :, 0], coords[:, :, 1]
298
+ regular = np.array_equal(xx, np.broadcast_to(xx[0], xx.shape)) and np.array_equal(yy, np.broadcast_to(yy[:, :1], yy.shape))
299
+ out['grid'] = {'x': xx[0].tolist() if regular else xx.tolist(),
300
+ 'y': yy[:, 0].tolist() if regular else yy.tolist()}
301
+ return out
302
+
303
+
304
+ def heatmap(ax, data, *, series, x=None, y=None, cells=False, include_values=False,
305
+ key=None, value_raster=False, scale=None, alpha_by=None, alpha_range=(0.25, 1.0),
306
+ alpha_norm="linear", **kwargs):
307
+ """Draw a scalar matrix. ``x``/``y`` select pcolormesh (including irregular grids).
308
+
309
+ ``cells=True`` gives modest meshes row/column cell IDs; above the raster
310
+ threshold only the layer is addressable. ``include_values`` stores raw values.
311
+ ``key`` names the recipe color controls; defaults to the owning axes and series.
312
+ ``value_raster=True`` writes the matrix as ``<plot>.<key>.values.json`` beside the SVG
313
+ (row-major, ``null`` for missing) and points the colour scale at it, so a consumer can
314
+ repaint an image layer from its values instead of regenerating. ``scale="name"`` joins a
315
+ shared scale declared with :func:`color_scale`. ``alpha_by`` (a matrix of the same shape —
316
+ p-values, counts, confidence) washes cells out: their alpha runs over ``alpha_range`` with
317
+ the value (``alpha_norm="log"`` for p-values); the manifest records the channel as
318
+ ``colorScales[].alpha`` and every cell carries ``data-alpha-value``.
319
+ """
320
+ arr = np.ma.masked_invalid(np.ma.asarray(data, dtype=float))
321
+ if arr.ndim != 2 or not arr.size:
322
+ raise ValueError('heatmap data must be a nonempty 2D scalar matrix')
323
+ if (x is None) != (y is None):
324
+ raise ValueError('heatmap x and y must be supplied together')
325
+ key = _options(ax, series, key, kwargs, scale=scale)
326
+ extend = kwargs.pop('_extend', None)
327
+ if isinstance(kwargs.get('cmap'), str):
328
+ kwargs['cmap'] = resolve_colormap(kwargs['cmap']) # 'emerald', 'crameri.batlow', 'batlow_r'
329
+ if x is not None or cells:
330
+ if x is None:
331
+ x, y = np.arange(arr.shape[1] + 1), np.arange(arr.shape[0] + 1)
332
+ artist = ax.pcolormesh(x, y, arr, **kwargs)
333
+ else:
334
+ artist = ax.imshow(arr, **kwargs)
335
+ if scale is not None:
336
+ join_scale(ax, scale, artist, arr)
337
+ config = {'kind': 'heatmap', 'shape': list(arr.shape), 'includeValues': bool(include_values),
338
+ 'controlKey': key}
339
+ if extend:
340
+ config['extend'] = extend
341
+ data = {'field_config': config, 'field_artist': artist, 'cells': bool(cells)}
342
+ if value_raster:
343
+ data['value_raster'] = {} # filename and payload are fixed at save time
344
+ if alpha_by is not None:
345
+ a = np.ma.filled(np.ma.asarray(alpha_by, dtype=float), np.nan)
346
+ if a.shape != arr.shape:
347
+ raise ValueError(f'heatmap: alpha_by must have the matrix shape {arr.shape}, got {a.shape}')
348
+ apply_alpha(artist, data, a, alpha_range=alpha_range, alpha_norm=alpha_norm, source='alpha_by',
349
+ shape=None if hasattr(artist, 'get_coordinates') else arr.shape)
350
+ mark = Mark(role='x-heatmap', series=series, kind='heatmap', artists=[artist], data=data)
351
+ tagger.registry_for(ax.figure).add(mark)
352
+ return artist
353
+
354
+
355
+ def _contour(ax, args, series, filled, include_values, key, kwargs, scale=None):
356
+ key = _options(ax, series, key, kwargs, scale=scale)
357
+ extend = kwargs.pop('_extend', None)
358
+ if extend:
359
+ kwargs['extend'] = extend # an edited extend replaces the script's: the bands change with it
360
+ if isinstance(kwargs.get('cmap'), str):
361
+ kwargs['cmap'] = resolve_colormap(kwargs['cmap'])
362
+ artist = (ax.contourf if filled else ax.contour)(*args, **kwargs)
363
+ if scale is not None:
364
+ join_scale(ax, scale, artist, args[0] if len(args) < 3 else args[2])
365
+ levels = values(artist.levels)
366
+ config = {'kind': 'contourf' if filled else 'contour', 'levels': levels,
367
+ 'extend': artist.extend, 'controlKey': key}
368
+ z = np.ma.masked_invalid(np.ma.asarray(args[0] if len(args) < 3 else args[2], dtype=float))
369
+ config['shape'] = list(z.shape)
370
+ config['maskedIndices'] = np.flatnonzero(np.ma.getmaskarray(z)).tolist()
371
+ if include_values: config['values'] = [values(row) for row in z]
372
+ if len(args) >= 3:
373
+ config['grid'] = {'x': np.asarray(args[0]).tolist(), 'y': np.asarray(args[1]).tolist()}
374
+ # 3.8 made ContourSet itself an Artist; 3.7 has one Collection per level.
375
+ if hasattr(artist, 'set_gid'):
376
+ artists = [artist]
377
+ split = True
378
+ else:
379
+ artists = list(artist.collections)
380
+ split = False
381
+ mark = Mark(role='x-contourf' if filled else 'x-contour', series=series,
382
+ kind=config['kind'], artists=artists, axes=ax,
383
+ data={'field_config': config, 'field_artist': artist, 'contour_paths': split, 'contour_legacy': not split})
384
+ tagger.registry_for(ax.figure).add(mark)
385
+ return artist
386
+
387
+
388
+ def contour(ax, *args, series, include_values=False, key=None, scale=None, **kwargs):
389
+ """Matplotlib contour with exact levels and addressable level paths."""
390
+ return _contour(ax, args, series, False, include_values, key, kwargs, scale=scale)
391
+
392
+
393
+ def contourf(ax, *args, series, include_values=False, key=None, scale=None, **kwargs):
394
+ """Matplotlib filled contours with exact boundaries and band identities."""
395
+ return _contour(ax, args, series, True, include_values, key, kwargs, scale=scale)
396
+
397
+
398
+ def _field_mark(fig, artist):
399
+ return next((m for m in tagger.registry_for(fig).marks if m.data.get('field_artist') is artist), None)
400
+
401
+
402
+ def colorbar(mappable=None, *, name='color', ax=None, scale=None, fig=None, **kwargs):
403
+ """Create a named, linked color key using Figure.colorbar's usual options.
404
+
405
+ The key follows its scale's recipe controls: an ``extend`` edited in Flux is applied here
406
+ (and recorded) unless the call names its own. ``scale="name"`` draws the one key of a shared
407
+ scale (:func:`color_scale`) — pass ``ax=[...]`` to let it borrow space from several panels.
408
+ """
409
+ import matplotlib.pyplot as plt
410
+ if scale is not None:
411
+ decl = shared_scale(fig if fig is not None else (ax[0] if isinstance(ax, (list, tuple)) else ax).figure
412
+ if ax is not None else plt.gcf(), scale)
413
+ if not decl.members:
414
+ raise ValueError(f"colour scale {scale!r} has no members yet; draw with scale={scale!r} first")
415
+ resolve_scales(decl.members[0][0].axes.figure) # the key shows the shared limits
416
+ mappable = decl.members[0][0]
417
+ if mappable is None:
418
+ raise ValueError('colorbar needs a mappable, or scale=')
419
+ owner = ax if ax is not None else getattr(mappable, 'axes', None)
420
+ if owner is None:
421
+ raise ValueError('colorbar needs ax when the mappable has no owning axes')
422
+ owner_axes = owner[0] if isinstance(owner, (list, tuple)) else owner
423
+ mark = _field_mark(owner_axes.figure, mappable)
424
+ if mark is not None and mark.data['field_config'].get('extend') and 'extend' not in kwargs:
425
+ kwargs['extend'] = mark.data['field_config']['extend']
426
+ cb = owner_axes.figure.colorbar(mappable, ax=owner, **kwargs)
427
+ if mark is not None:
428
+ mark.data['field_config']['extend'] = cb.extend
429
+ cb.ax._fluxplot_colorbar_name = str(name)
430
+ cb.ax._fluxplot_owner_axes = owner_axes
431
+ return cb
432
+
433
+
434
+ def paint_of(artist) -> str:
435
+ """Which paint properties a colour scale drives on an artist's elements: ``"fill"`` for filled
436
+ shapes and images, ``"stroke"`` for line contours and line collections, ``"fill stroke"`` for a
437
+ collection whose edges take the face colour (``edgecolor="face"``: hexagons, scatter markers)."""
438
+ from matplotlib.collections import Collection, LineCollection
439
+ from matplotlib.contour import ContourSet
440
+ from matplotlib.image import AxesImage
441
+ if isinstance(artist, ContourSet):
442
+ return 'fill' if artist.filled else 'stroke'
443
+ if isinstance(artist, AxesImage):
444
+ return 'fill'
445
+ if isinstance(artist, LineCollection):
446
+ return 'stroke'
447
+ if isinstance(artist, Collection):
448
+ ec, fc = artist.get_edgecolor(), artist.get_facecolor()
449
+ if len(fc) and len(ec) and np.array_equal(ec, fc):
450
+ return 'fill stroke'
451
+ if not len(fc):
452
+ return 'stroke'
453
+ return 'fill'
454
+
455
+
456
+ def capture_mark(mark):
457
+ if 'field_config' not in mark.data: return
458
+ artist, resolve = mark.data['field_artist'], mark.data.get('field_resolve')
459
+ field = _capture(artist, mark.data['field_config'], resolve)
460
+ mark.data['field'] = field
461
+ from matplotlib.image import AxesImage
462
+ recolor = 'regenerate' if isinstance(artist, AxesImage) else 'live'
463
+ raster = mark.data.get('value_raster')
464
+ if recolor == 'regenerate' and raster:
465
+ recolor = 'raster' # the values travel beside the SVG: a canvas can repaint them
466
+ mark.data['color_scale'] = colorscale.scale_record(
467
+ field['controlKey'], artist, label=field.get('label'), extend=field['extend'], recolor=recolor)
468
+ if mark.data.get('alpha_channel'): # value × confidence: the second channel of the scale
469
+ mark.data['color_scale']['alpha'] = dict(mark.data['alpha_channel'])
470
+ mark.data['color_paint'] = paint_of(artist)
471
+ if raster:
472
+ arr = np.ma.masked_invalid(np.ma.asarray(artist.get_array(), dtype=float))
473
+ raster['payload'] = {'spec': 'fluxplot/values', 'scale': field['controlKey'],
474
+ 'shape': list(arr.shape), 'values': values(arr.reshape(-1))}
475
+ mark.data['color_scale']['valueRaster'] = raster['filename']
476
+
477
+
478
+ def anonymous_scales(ax, reg):
479
+ """Colour scales for the raw colour-mapped artists on ``ax`` no helper tagged: every image
480
+ or collection with a data array (``imshow``, ``pcolormesh``, ``scatter(c=…)``, a contour set)
481
+ gets a scale named after its own gid, so its colour key links and its colours are editable.
482
+ Returns ``[{"gid", "record", "paint"}]``; there is no series — just the scale."""
483
+ from matplotlib.image import AxesImage
484
+ tagged = {id(a) for m in reg.marks for a in m.artists}
485
+ out = []
486
+ for art in list(ax.images) + list(ax.collections):
487
+ if id(art) in tagged or not art.get_gid() or not art.get_visible():
488
+ continue
489
+ if getattr(art, 'get_array', None) is None or art.get_array() is None:
490
+ continue
491
+ gid = art.get_gid()
492
+ record = colorscale.scale_record(gid, art, recolor='regenerate' if isinstance(art, AxesImage) else 'live')
493
+ out.append({'gid': gid, 'record': record, 'paint': paint_of(art)})
494
+ return out
495
+
496
+
497
+ class vector_colorbars:
498
+ """Render colour-key solids as vectors (matplotlib rasterizes them by default) so postprocess
499
+ can replace the quads with one exact gradient rect; restores each colorbar's setting after."""
500
+
501
+ def __init__(self, fig):
502
+ from .panels import all_axes
503
+ self.solids = [ax._colorbar.solids for ax in all_axes(fig)
504
+ if getattr(ax, '_colorbar', None) is not None and ax._colorbar.solids is not None]
505
+
506
+ def __enter__(self):
507
+ self.before = [(s, s.get_rasterized()) for s in self.solids]
508
+ for s in self.solids:
509
+ s.set_rasterized(False)
510
+
511
+ def __exit__(self, *exc):
512
+ for s, was in self.before:
513
+ s.set_rasterized(was)
514
+
515
+
516
+ def _tick_kinds(axis):
517
+ """Portable names for the colour key's tick locator and formatter classes."""
518
+ from .capture import tick_kinds
519
+ return tick_kinds(axis)
520
+
521
+
522
+ def _gradient(cb, fig):
523
+ """The exact vector form of a colour key's solids: one hard-stepped gradient along its long
524
+ axis. Offsets are the quad boundaries (``cb._y``) in SVG user units along the axis — uniform
525
+ in the axis' own scale, whatever the norm — and the colours are the quads' own."""
526
+ from matplotlib.colors import to_hex
527
+ from .capture import data_to_svg
528
+ vertical = cb.orientation == 'vertical'
529
+ y = np.asarray(cb._y, dtype=float)
530
+ along = np.array([(data_to_svg(cb.ax, fig, 0.5, v) if vertical else data_to_svg(cb.ax, fig, v, 0.5))
531
+ [1 if vertical else 0] for v in y])
532
+ span = along[-1] - along[0]
533
+ offsets = (along - along[0]) / span if span else np.linspace(0, 1, len(along))
534
+ values = np.asarray(cb._values)[cb._inside]
535
+ rgba = cb.cmap(cb.norm(values))
536
+ colours = [to_hex(c, keep_alpha=False) for c in rgba]
537
+ alpha = float(cb.alpha) if cb.alpha is not None else None
538
+ opacities = [round(float(c[3]) * (alpha if alpha is not None else 1.0), 6) for c in rgba]
539
+ if len(colours) != len(offsets) - 1:
540
+ return None
541
+ return {'axis': 'y' if vertical else 'x', 'start': float(along[0]), 'end': float(along[-1]),
542
+ 'offsets': [float(o) for o in offsets], 'colors': colours, 'opacities': opacities}
543
+
544
+
545
+ def colorbar_guides(fig, owner, alloc):
546
+ from .panels import all_axes
547
+ from .capture import data_to_svg
548
+ guides = []
549
+ for ax in all_axes(fig):
550
+ cb = getattr(ax, '_colorbar', None)
551
+ if cb is None: continue
552
+ parent = getattr(ax, '_fluxplot_owner_axes', getattr(cb.mappable, 'axes', None))
553
+ if parent is not owner: continue
554
+ gid = alloc.take('colorbar.' + ids.slugify(getattr(ax, '_fluxplot_colorbar_name', 'color')))
555
+ ax.set_gid(gid)
556
+ vertical = cb.orientation == 'vertical'
557
+ axis = ax.yaxis if vertical else ax.xaxis
558
+ parts = []
559
+ artists = [('label', axis.label), ('outline', cb.outline)]
560
+ primary_side = 2 if axis.get_ticks_position() in ('right', 'top') else 1
561
+ artists.extend((suffix.replace('ticklabel', 'tick-label'), art)
562
+ for suffix, role, _, art in tagger.axis_tick_artists(axis, primary_side)
563
+ if role in ('tick', 'tick-label', 'gridline'))
564
+ if cb.solids is not None: artists.append(('solids', cb.solids))
565
+ # the extend triangles / rectangles beyond the ends: lower first, then upper
566
+ extend_parts = {}
567
+ sides = [side for side, on in (('min', cb._extend_lower()), ('max', cb._extend_upper())) if on]
568
+ for side, patch in zip(sides, getattr(cb, '_extend_patches', [])):
569
+ artists.append((f'extend-{side}', patch))
570
+ for suffix, art in artists:
571
+ if not art.get_visible(): continue
572
+ part = gid + '.' + suffix
573
+ # Surface keys may already have explicit registered identity.
574
+ if art.get_gid(): part = art.get_gid()
575
+ else: art.set_gid(part)
576
+ role = next(token for token in suffix.split('.')
577
+ if token not in ('minor', 'secondary'))
578
+ if role.startswith('extend-'):
579
+ extend_parts[role[len('extend-'):]] = part
580
+ role = 'extend'
581
+ kind = {'label': 'text', 'tick-label': 'text', 'tick': 'line',
582
+ 'gridline': 'line', 'outline': 'line', 'solids': 'shape', 'extend': 'shape'}[role]
583
+ parts.append({'svgId': part, 'role': 'colorbar-' + role, 'kind': kind})
584
+ guides.append(GuideTag(gid=part, role=parts[-1]['role'], kind=kind))
585
+ lo, hi = sorted(axis.get_view_interval())
586
+ visible_ticks = [t.get_loc() for t in axis.get_major_ticks()
587
+ if np.isfinite(t.get_loc()) and lo <= t.get_loc() <= hi
588
+ and t.get_visible() and any(a.get_visible() for a in
589
+ (t.tick1line, t.tick2line, t.label1, t.label2))]
590
+ # data ↔ SVG anchors along the long axis, exactly as capture_axes does for plot axes
591
+ anchors = []
592
+ norm = cb.norm
593
+ for value in (norm.vmin, getattr(norm, 'vcenter', None), norm.vmax):
594
+ if value is None or not np.isfinite(value): continue
595
+ sx, sy = data_to_svg(ax, fig, 0.5, value) if vertical else data_to_svg(ax, fig, value, 0.5)
596
+ anchors.append({'value': float(value), 'svg': sy if vertical else sx})
597
+ locator, formatter = _tick_kinds(axis)
598
+ data = {'orientation': cb.orientation, 'label': axis.label.get_text(),
599
+ 'ticks': values(visible_ticks), 'normalization': normalization(cb.mappable),
600
+ 'cmap': cb.mappable.get_cmap().name, 'parts': parts,
601
+ 'mappable': cb.mappable.get_gid() if hasattr(cb.mappable, 'get_gid') else None,
602
+ 'anchors': anchors, 'axisLength': abs(anchors[-1]['svg'] - anchors[0]['svg']) if len(anchors) > 1 else None,
603
+ 'tickLocator': locator, 'tickFormatter': formatter, 'extend': cb.extend}
604
+ if extend_parts:
605
+ data['extendParts'] = extend_parts
606
+ if cb.solids is not None and cb.solids.get_visible():
607
+ gradient = _gradient(cb, fig)
608
+ if gradient is not None:
609
+ data['_gradient'] = dict(gradient, solids=cb.solids.get_gid()) # consumed by postprocess
610
+ guides.append(GuideTag(gid=gid, role='colorbar', data=data))
611
+ return guides