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/api.py ADDED
@@ -0,0 +1,1196 @@
1
+ """Public API — free functions over real matplotlib artists (spec §11.1).
2
+
3
+ Free functions (not a wrapped Axes) keep the user in full matplotlib (P2): the convenience helpers
4
+ and the ``tag()`` escape hatch are the same shape, and untagged matplotlib keeps working. ``save()``
5
+ orchestrates the pipeline: assign gids → draw → auto-tag scaffold → capture coords → render
6
+ deterministically → inject ``data-*`` → emit manifest + recipe.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import fnmatch
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import stat
15
+ import sys
16
+ import warnings
17
+ from dataclasses import dataclass, field
18
+
19
+ import matplotlib
20
+
21
+ from . import autotag as _autotag
22
+ from . import canonical_json as _cjson
23
+ from . import capture as _capture
24
+ from . import ids as _ids
25
+ from . import postprocess as _postprocess
26
+ from . import raster as _raster
27
+ from . import recipe as _recipe
28
+ from . import render as _render
29
+ from . import roles as _roles
30
+ from . import tagger as _tagger
31
+ from .descriptors import Mark
32
+ from . import data as _data
33
+ from . import panels as _panels
34
+ from types import SimpleNamespace
35
+ import numpy as np
36
+ from .version import SPEC_VERSION, __version__
37
+
38
+
39
+ def _list(a):
40
+ return None if a is None else list(np.atleast_1d(a))
41
+
42
+
43
+ def _env_flag(name: str) -> bool:
44
+ """A truthy environment switch: anything but unset/empty/0/false/no/off."""
45
+ return os.environ.get(name, "").strip().lower() not in ("", "0", "false", "no", "off")
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # convenience helpers (auto-tagging) — each returns the real matplotlib artist(s)
50
+ # ---------------------------------------------------------------------------
51
+ def _series_color_override(series):
52
+ """The colour Flux asked this series to take on a rerun: ``__fluxplot__.series[<id>].color``
53
+ (keyed by the series id, i.e. its slug), or ``None``."""
54
+ controls = _recipe.params().get("__fluxplot__") or {}
55
+ per_series = controls.get("series") or {}
56
+ if not isinstance(per_series, dict):
57
+ raise ValueError("FLUX_PARAMS __fluxplot__.series must map series ids to {'color': ...}")
58
+ entry = per_series.get(_ids.series_root(series)) or per_series.get(str(series))
59
+ if isinstance(entry, dict):
60
+ return entry.get("color")
61
+ return entry if isinstance(entry, str) else None
62
+
63
+
64
+ def _series_color(series, kw, *, key="color", auto=True):
65
+ """Apply the series' colour rule to a helper's keywords: a recipe override wins; otherwise a
66
+ colour comes from the category registry when ``fp.colors.categories.auto_series`` is on and
67
+ the call gave none; otherwise the call's own (or matplotlib's cycle) stands."""
68
+ from .colors import categories
69
+ override = _series_color_override(series)
70
+ if override is not None:
71
+ kw.pop("c", None)
72
+ kw[key] = override
73
+ elif auto and categories.auto_series and key not in kw and "c" not in kw:
74
+ kw[key] = categories.get(series)
75
+ return kw
76
+
77
+
78
+ def line(ax, x, y, *, series, marker=None, label=None, **kw):
79
+ """A named line. With ``marker=`` it also draws an addressable per-point group."""
80
+ reg = _tagger.registry_for(ax.figure)
81
+ _series_color(series, kw)
82
+ (ln,) = ax.plot(x, y, label=label, **kw)
83
+ reg.add(Mark(role="line", series=series, kind="line", live_data=True, x=None, y=None, label=label, artists=[ln]))
84
+ if marker:
85
+ # Copy the resolved Line2D style, including aliases/defaults, then disable
86
+ # its stroke. This keeps the established (line, points) return contract.
87
+ from matplotlib.lines import Line2D
88
+ pts = Line2D([], [])
89
+ pts.update_from(ln)
90
+ pts.set_zorder(ln.get_zorder())
91
+ pts.set_markevery(ln.get_markevery())
92
+ pts.set_data(x, y)
93
+ pts.set_linestyle("none")
94
+ pts.set_marker(marker)
95
+ pts.set_label("_nolegend_")
96
+ ax.add_line(pts)
97
+ reg.add(Mark(role="point", series=series, kind="line", live_data=True, x=None, y=None, artists=[pts], indexed=True))
98
+ return ln, pts
99
+ return ln
100
+
101
+
102
+ def _is_value_array(c, x) -> bool:
103
+ """matplotlib's own rule for ``scatter(c=…)``: a 1-D numeric array as long as ``x`` is
104
+ colour-MAPPED (through cmap and norm); anything else is a colour or a list of colours."""
105
+ if c is None or isinstance(c, str):
106
+ return False
107
+ arr = np.asarray(c)
108
+ return arr.ndim == 1 and arr.dtype.kind in "iuf" and arr.size == np.size(x)
109
+
110
+
111
+ def scatter(ax, x, y, *, series, label=None, key=None, scale=None, alpha_by=None, alpha_range=(0.25, 1.0),
112
+ alpha_norm="linear", **kw):
113
+ """A named scatter, every point addressable.
114
+
115
+ With ``c=`` an array of values the points are colour-mapped: the scale becomes a recipe colour
116
+ control (named ``key``, default the series), the manifest carries its exact lookup table, each
117
+ point carries its value (``data-value``) and a :func:`colorbar` links to it — so Flux can
118
+ recolour or re-range the points live.
119
+ """
120
+ reg = _tagger.registry_for(ax.figure)
121
+ data: dict = {}
122
+ if not _is_value_array(kw.get("c"), x):
123
+ _series_color(series, kw)
124
+ if _is_value_array(kw.get("c"), x):
125
+ from . import fields as _fields
126
+ from ._fieldmap import resolve_colormap
127
+ ctl = _fields._options(ax, series, key, kw, scale=scale)
128
+ extend = kw.pop("_extend", None)
129
+ if isinstance(kw.get("cmap"), str):
130
+ kw["cmap"] = resolve_colormap(kw["cmap"])
131
+ config = {"kind": "scatter", "controlKey": ctl, "shape": [int(np.size(kw["c"]))]}
132
+ if extend:
133
+ config["extend"] = extend
134
+ data = {"field_config": config}
135
+ coll = ax.scatter(x, y, label=label, **kw)
136
+ if data:
137
+ data["field_artist"] = coll
138
+ data["c"] = _data.values(coll.get_array())
139
+ if scale is not None:
140
+ _fields.join_scale(ax, scale, coll)
141
+ sizes = coll.get_sizes()
142
+ if len(sizes) > 1:
143
+ data["size"] = _data.values(sizes)
144
+ if alpha_by is not None:
145
+ from .fields import apply_alpha
146
+ a = np.ma.filled(np.ma.asarray(alpha_by, dtype=float), np.nan).ravel()
147
+ if a.size != len(coll.get_offsets()):
148
+ raise ValueError(f"scatter: alpha_by needs one value per point ({len(coll.get_offsets())}), got {a.size}")
149
+ apply_alpha(coll, data, a, alpha_range=alpha_range, alpha_norm=alpha_norm, source="alpha_by")
150
+ reg.add(Mark(role="point", series=series, kind="scatter", live_data=True, x=None, y=None, label=label,
151
+ artists=[coll], indexed=True, data=data))
152
+ return coll
153
+
154
+
155
+ def bar(ax, x, height, *, series, label=None, **kw):
156
+ reg = _tagger.registry_for(ax.figure)
157
+ _series_color(series, kw, auto=False)
158
+ container = ax.bar(x, height, label=label, **kw)
159
+ reg.add(Mark(role="bar", series=series, kind="bar", live_data=True, x=None, y=None, label=label, artists=list(container.patches), indexed=True))
160
+ return container
161
+
162
+
163
+ def barh(ax, y, width, *, series, label=None, **kw):
164
+ """A horizontal named bar series; returns the ordinary BarContainer."""
165
+ _series_color(series, kw, auto=False)
166
+ container = ax.barh(y, width, label=label, **kw)
167
+ _tagger.registry_for(ax.figure).add(Mark(
168
+ role="bar", series=series, kind="bar", live_data=True, label=label,
169
+ artists=list(container.patches), indexed=True,
170
+ data={"bar": {"orientation": "horizontal"}}))
171
+ return container
172
+
173
+
174
+ def _err_payload(err, n):
175
+ """An errorbar's ``xerr`` / ``yerr`` broadcast to the N points, and its shape: ``scalar``
176
+ (one value for all), ``symmetric`` (one per point) or ``asymmetric`` (``[lower, upper]``)."""
177
+ if err is None:
178
+ return None, None
179
+ arr = np.asarray(err, dtype=float)
180
+ if arr.ndim == 0:
181
+ return [float(arr)] * n, "scalar"
182
+ if arr.ndim == 1:
183
+ return _data.values(np.broadcast_to(arr, (n,))), "symmetric"
184
+ if arr.ndim == 2 and arr.shape[0] == 2:
185
+ return [_data.values(np.broadcast_to(arr[0], (n,))), _data.values(np.broadcast_to(arr[1], (n,)))], "asymmetric"
186
+ raise ValueError(f"errorbar: an error array must be a scalar, N values or a (2, N) array; got shape {arr.shape}")
187
+
188
+
189
+ def errorbar(ax, x, y, *, series, yerr=None, xerr=None, label=None, **kw):
190
+ """Points with error bars, each part addressable: the data line (``<series>.line``, when the
191
+ format draws one), the markers (``<series>.point.k``, when a marker is set), the caps
192
+ (``<series>.cap``, …) and the bars (``<series>.errorbar``, …). ``uncertainty`` records
193
+ ``xerr`` / ``yerr`` broadcast to the N points with ``errShape`` (scalar / symmetric /
194
+ asymmetric)."""
195
+ from matplotlib.lines import Line2D
196
+ reg = _tagger.registry_for(ax.figure)
197
+ _series_color(series, kw, auto=False)
198
+ container = ax.errorbar(x, y, yerr=yerr, xerr=xerr, label=label, **kw)
199
+ data_line, caps, barlinecols = container
200
+ xs, ys = _data.converted(ax, x, y)
201
+ n = len(xs)
202
+ yv, shape = _err_payload(yerr, n)
203
+ xv, xshape = _err_payload(xerr, n)
204
+ uncertainty = {"xerr": xv, "yerr": yv, "errShape": shape or xshape}
205
+ # the bars carry the series' data (compat: svg.errorbar keeps pointing at them)
206
+ reg.add(Mark(role="errorbar", series=series, kind="errorbar", x=xs, y=ys, label=label,
207
+ artists=list(barlinecols), data={"uncertainty": uncertainty}))
208
+ if caps:
209
+ reg.add(Mark(role="cap", series=series, kind="errorbar", artists=list(caps)))
210
+ if data_line is not None:
211
+ has_line = str(data_line.get_linestyle()).lower() not in ("none", "", " ")
212
+ has_marker = str(data_line.get_marker()).lower() not in ("none", "", " ")
213
+ if has_marker:
214
+ # markers on their own Line2D (as fp.line does) so each is a <use> with a point id;
215
+ # the data line keeps its stroke and loses the marker
216
+ pts = Line2D([], [])
217
+ pts.update_from(data_line)
218
+ pts.set_zorder(data_line.get_zorder())
219
+ pts.set_data(data_line.get_xdata(orig=False), data_line.get_ydata(orig=False))
220
+ pts.set_linestyle("none")
221
+ pts.set_label("_nolegend_")
222
+ ax.add_line(pts)
223
+ reg.add(Mark(role="point", series=series, kind="errorbar", live_data=True, x=None, y=None,
224
+ artists=[pts], indexed=True))
225
+ if has_line:
226
+ data_line.set_marker("none")
227
+ if has_line:
228
+ reg.add(Mark(role="line", series=series, kind="errorbar", live_data=True, x=None, y=None,
229
+ artists=[data_line]))
230
+ elif has_marker:
231
+ data_line.set_visible(False) # the copy draws the markers; nothing is drawn twice
232
+ return container
233
+
234
+
235
+ def legend(ax, handles=None, labels=None, **kw):
236
+ """``ax.legend`` with the entry → artist mapping kept, so the manifest can say which series
237
+ each legend entry names even when the handles were chosen by hand
238
+ (``fp.legend(ax, [ln], ["Control"])``). Without ``handles`` the axes' own labelled artists
239
+ are used, in matplotlib's order, and the mapping is recovered from them at save."""
240
+ if handles is None and labels is None:
241
+ leg = ax.legend(**kw)
242
+ elif handles is None:
243
+ leg = ax.legend(labels, **kw)
244
+ elif labels is None:
245
+ leg = ax.legend(handles=handles, **kw)
246
+ else:
247
+ leg = ax.legend(handles, labels, **kw)
248
+ leg._fluxplot_handles = list(handles) if handles is not None else None
249
+ return leg
250
+
251
+
252
+ def step(ax, x, y, *, series, where="pre", label=None, **kw):
253
+ """A step line (``Axes.step``) whose ``where`` (``pre`` / ``post`` / ``mid``) and drawstyle
254
+ the manifest records under ``step``, so a consumer re-projects the treads, not the corners."""
255
+ if where not in ("pre", "post", "mid"):
256
+ raise ValueError(f"step: where must be 'pre', 'post' or 'mid', got {where!r}")
257
+ reg = _tagger.registry_for(ax.figure)
258
+ _series_color(series, kw)
259
+ (ln,) = ax.step(x, y, where=where, label=label, **kw)
260
+ reg.add(Mark(role="line", series=series, kind="step", live_data=True, x=None, y=None, label=label, artists=[ln],
261
+ data={"step": {"where": where, "drawstyle": ln.get_drawstyle()}}))
262
+ return ln
263
+
264
+
265
+ def stem(ax, x, y, *, series, label=None, **kw):
266
+ """A stem plot as one series: the markers (``<series>.point.k``), the stems
267
+ (``<series>.segment``) and the baseline (``<series>.baseline``)."""
268
+ reg = _tagger.registry_for(ax.figure)
269
+ _series_color(series, kw, key="linefmt", auto=False) if "linefmt" in kw else None
270
+ container = ax.stem(x, y, label=label, **kw)
271
+ markerline, stemlines, baseline = container.markerline, container.stemlines, container.baseline
272
+ xs, ys = _data.converted(ax, x, y)
273
+ reg.add(Mark(role="point", series=series, kind="stem", live_data=True, x=None, y=None, label=label,
274
+ artists=[markerline], indexed=True))
275
+ reg.add(Mark(role="segment", series=series, kind="stem", x=xs, y=ys, artists=[stemlines],
276
+ data={"stem": {"baseline": float(baseline.get_ydata()[0]) if len(baseline.get_ydata()) else 0.0,
277
+ "orientation": kw.get("orientation", "vertical")}}))
278
+ reg.add(Mark(role="reference-line", series=series, name="baseline", kind="stem", artists=[baseline]))
279
+ return container
280
+
281
+
282
+ def secondary_axis(ax, location, *, functions, label=None, **kw):
283
+ """A secondary x (``"top"`` / ``"bottom"``) or y (``"right"`` / ``"left"``) axis showing the
284
+ same data through ``functions=(forward, inverse)`` — wavelength beside frequency, mm beside
285
+ pixels. It is tagged as the panel's ``axis.x2`` / ``axis.y2`` and the manifest records the
286
+ transform as samples (``axes[].x2.secondary.samples``), so a consumer can re-tick it."""
287
+ if location in ("top", "bottom"):
288
+ sec = ax.secondary_xaxis(location, functions=functions, **kw)
289
+ if label:
290
+ sec.set_xlabel(label)
291
+ elif location in ("left", "right"):
292
+ sec = ax.secondary_yaxis(location, functions=functions, **kw)
293
+ if label:
294
+ sec.set_ylabel(label)
295
+ else:
296
+ raise ValueError(f"secondary_axis: location must be 'top', 'bottom', 'left' or 'right', got {location!r}")
297
+ return sec
298
+
299
+
300
+ def _broadcast(v, n):
301
+ arr = np.asarray(v, dtype=float)
302
+ return _data.values(np.broadcast_to(arr, (n,)) if arr.ndim == 0 else arr)
303
+
304
+
305
+ def area(ax, x, y1, y2=0, *, series, label=None, **kw):
306
+ """A filled region between ``y1`` and ``y2`` (default the baseline 0) — ``fill_between`` with
307
+ its inputs recorded: the manifest's ``band`` carries ``{x, y1, y2}`` (a scalar ``y2`` is
308
+ broadcast), not the polygon's vertices."""
309
+ reg = _tagger.registry_for(ax.figure)
310
+ _series_color(series, kw, auto=False)
311
+ poly = ax.fill_between(x, y1, y2, label=label, **kw)
312
+ xs = _data.values(x)
313
+ payload = {"x": xs, "y1": _broadcast(y1, len(xs)), "y2": _broadcast(y2, len(xs))}
314
+ reg.add(Mark(role="area", series=series, kind="area", x=None, y=None, label=label, artists=[poly],
315
+ data={"band": payload}))
316
+ return poly
317
+
318
+
319
+ def band(ax, x, lo, hi, *, series, what="95% CI", label=None, **fill_kw):
320
+ """An uncertainty band around a series' line: ``fill_between(x, lo, hi)`` registered under the
321
+ **same series** as the line, so ``ctl.band`` sits beside ``ctl.line``.
322
+
323
+ ``what`` says what the band is (``"95% CI"``, ``"SEM"``, ``"IQR"``, …) and is recorded with
324
+ the inputs in the manifest: ``band = {x, lo, hi, what}``. The band takes the series' line
325
+ colour when one is drawn already (else the call's, else the cycle's), at ``alpha=0.25`` with
326
+ no edge unless the call says otherwise.
327
+ """
328
+ reg = _tagger.registry_for(ax.figure)
329
+ fill_kw.setdefault("alpha", 0.25)
330
+ fill_kw.setdefault("linewidth", 0)
331
+ _series_color(series, fill_kw, auto=False)
332
+ if not any(k in fill_kw for k in ("color", "facecolor", "fc", "c")):
333
+ line = next((m for m in reg.marks if m.series == series and m.role == "line" and m.artists), None)
334
+ if line is not None and hasattr(line.artists[0], "get_color"):
335
+ fill_kw["color"] = line.artists[0].get_color()
336
+ poly = ax.fill_between(x, lo, hi, label=label, **fill_kw)
337
+ xs = _data.values(x)
338
+ payload = {"x": xs, "lo": _broadcast(lo, len(xs)), "hi": _broadcast(hi, len(xs)), "what": str(what)}
339
+ reg.add(Mark(role="area", series=series, name="band", kind="area", x=None, y=None, label=label,
340
+ artists=[poly], data={"band": payload}))
341
+ return poly
342
+
343
+
344
+ def box(ax, values, *, series, label=None, include_values=False, **kw):
345
+ """One box-and-whisker group as ONE semantic series with addressable statistics.
346
+
347
+ Wraps ``Axes.boxplot`` for a single dataset. The documented return-dict pieces — box body,
348
+ whiskers, caps, median, fliers (and mean when shown) — become tagged sub-parts grouped per
349
+ series (``<series>.whiskers``, ``<series>.caps``, …). Multiple groups = multiple calls with
350
+ distinct ``series`` names. Raw sample values are recorded only with ``include_values=True``
351
+ (samples can be large or sensitive). Returns matplotlib's boxplot dict unchanged.
352
+ """
353
+ reg = _tagger.registry_for(ax.figure)
354
+ bp = ax.boxplot(values, **kw)
355
+ if len(bp["boxes"]) != 1:
356
+ raise ValueError(
357
+ "fp.box tags one box per call — pass a single dataset, and call fp.box once per "
358
+ "group with a distinct stable series= name"
359
+ )
360
+ body = bp["boxes"][0]
361
+ if label:
362
+ body.set_label(label)
363
+ data = {"distribution": {"values": _data.values(values)}} if include_values else {}
364
+ reg.add(Mark(role="box", series=series, kind="box", label=label, artists=[body], data=data))
365
+ for role, key in (
366
+ ("whisker", "whiskers"), ("cap", "caps"), ("median", "medians"),
367
+ ("flier", "fliers"), ("mean", "means"),
368
+ ):
369
+ artists = [a for a in bp.get(key, []) if a is not None]
370
+ if artists: # options that were off create no dead parts
371
+ reg.add(Mark(role=role, series=series, kind="box", artists=artists))
372
+ return bp
373
+
374
+
375
+ def violin(ax, values, *, series, label=None, include_values=False, **kw):
376
+ """One violin as ONE semantic series with addressable statistics.
377
+
378
+ Wraps ``Axes.violinplot`` for a single dataset: the body plus whatever the call returned
379
+ (extrema bar, min/max caps, median/mean/quantile lines) each become tagged sub-parts —
380
+ absent options create no dead parts. Multiple groups = multiple calls with distinct
381
+ ``series`` names. Raw sample values are recorded only with ``include_values=True``.
382
+ Returns matplotlib's violinplot dict unchanged.
383
+ """
384
+ reg = _tagger.registry_for(ax.figure)
385
+ vp = ax.violinplot(values, **kw)
386
+ bodies = vp.get("bodies") or []
387
+ if len(bodies) != 1:
388
+ raise ValueError(
389
+ "fp.violin tags one violin per call — pass a single dataset, and call fp.violin "
390
+ "once per group with a distinct stable series= name"
391
+ )
392
+ body = bodies[0]
393
+ if label:
394
+ body.set_label(label)
395
+ data = {"distribution": {"values": _data.values(values)}} if include_values else {}
396
+ reg.add(Mark(role="violin", series=series, kind="violin", label=label, artists=[body], data=data))
397
+ caps = [vp[k] for k in ("cmins", "cmaxes") if vp.get(k) is not None]
398
+ if caps:
399
+ reg.add(Mark(role="cap", series=series, kind="violin", artists=caps))
400
+ for role, key in (
401
+ ("whisker", "cbars"), ("median", "cmedians"), ("mean", "cmeans"), ("segment", "cquantiles"),
402
+ ):
403
+ art = vp.get(key)
404
+ if art is not None:
405
+ reg.add(Mark(role=role, series=series, kind="violin", artists=[art]))
406
+ return vp
407
+
408
+
409
+ def hist(ax, values, *, series, bins=None, label=None, include_values=False, **kw):
410
+ """A histogram as an indexed bar series with its exact distribution recorded.
411
+
412
+ Wraps ``Axes.hist`` (single dataset, default bar histtype). The returned patches become
413
+ per-index addressable bars; the manifest additionally carries the exact ``binEdges`` and
414
+ ``counts`` in an additive ``distribution`` payload. Bar heights are never presented as the
415
+ original observations — pass ``include_values=True`` to record the source values (they can
416
+ be large or sensitive). Returns matplotlib's ``(counts, edges, patches)`` unchanged.
417
+ """
418
+ from matplotlib.container import BarContainer
419
+
420
+ reg = _tagger.registry_for(ax.figure)
421
+ _series_color(series, kw, auto=False)
422
+ counts, edges, patches = ax.hist(values, bins=bins, label=label, **kw)
423
+ if not isinstance(patches, BarContainer):
424
+ raise ValueError(
425
+ "fp.hist tags one dataset with the default bar histtype — multiple datasets or "
426
+ "histtype='step' have no exact per-bar contract; call fp.hist once per series"
427
+ )
428
+ edges_f = [float(e) for e in edges]
429
+ counts_f = _data.values(counts)
430
+ centers = [(edges_f[k] + edges_f[k + 1]) / 2.0 for k in range(len(counts_f))]
431
+ dist = {"binEdges": edges_f, "counts": counts_f,
432
+ "normalization": "density" if kw.get("density") else "count",
433
+ "weighted": kw.get("weights") is not None,
434
+ "cumulative": kw.get("cumulative", False),
435
+ "orientation": kw.get("orientation", "vertical")}
436
+ if include_values:
437
+ dist["values"] = _data.values(values)
438
+ reg.add(
439
+ Mark(role="bar", series=series, kind="bar", x=centers, y=counts_f, label=label,
440
+ artists=list(patches.patches), indexed=True, live_data=True,
441
+ data={"distribution": dist, "bar": {"orientation": kw.get("orientation", "vertical")}})
442
+ )
443
+ return counts, edges, patches
444
+
445
+
446
+ # ---------------------------------------------------------------------------
447
+ # escape hatch — tag arbitrary raw matplotlib artists
448
+ # ---------------------------------------------------------------------------
449
+ def tag(artist, *, role, series=None, index=None, name=None, x=None, y=None, **identity):
450
+ """Tag any raw matplotlib artist with a semantic role + identity.
451
+
452
+ ``x``/``y`` capture the mark's data coordinates. When omitted, they are read from the
453
+ artist itself for supported types (``Line2D`` data, scatter offsets — the same exact
454
+ adapters save-time promotion uses); otherwise they stay honestly absent rather than
455
+ making an invalid spatial claim.
456
+ """
457
+ reg = _tagger.registry_for(_tagger.fig_of(artist))
458
+ live = x is None and y is None
459
+ if live:
460
+ x, y = _autotag.extract_xy(artist)
461
+ data = dict(identity)
462
+ if index is not None:
463
+ data["index"] = index
464
+ reg.add(
465
+ Mark(role=_roles.validate(role), series=series, name=name, x=_list(x), y=_list(y),
466
+ artists=[artist], data=data, live_data=live, indexed=index is not None)
467
+ )
468
+ return artist
469
+
470
+
471
+ def tag_points(points, *, series, x=None, y=None):
472
+ """Tag an existing markers ``Line2D`` / ``PathCollection`` as an addressable point group."""
473
+ reg = _tagger.registry_for(_tagger.fig_of(points))
474
+ live = x is None and y is None
475
+ if live:
476
+ x, y = _autotag.extract_xy(points) # Line2D data or exact finite scatter offsets
477
+ reg.add(Mark(role="point", series=series, kind="scatter", live_data=live, x=_list(x), y=_list(y), artists=[points], indexed=True))
478
+ return points
479
+
480
+
481
+ def tag_seaborn(ax, *, series=None, plot=None, data=None, x=None, y=None, hue=None, order=None, hue_order=None):
482
+ """Auto-tag the artists a seaborn axes-level plot drew on ``ax`` — one call, done.
483
+
484
+ Call it right after the seaborn call (and before raw-matplotlib additions you tag
485
+ yourself). Series names come from ``series=[...]`` if given, else from the frame
486
+ (``data=``, ``x=``, ``y=``, ``hue=`` — the same arguments the seaborn call took, so the hue
487
+ levels come out in seaborn's own order), else the legend's labels for a known ``plot=``
488
+ adapter, else the y-axis label. Multi-hue plots require ``plot='lineplot'`` (or
489
+ ``histplot``, ``barplot``, ``kdeplot``, etc.) because legend order alone does not identify
490
+ Seaborn's drawing order. An explicit ``series=[...]`` always means artist draw order. Per
491
+ series it names:
492
+
493
+ - data-carrying lines → role ``line`` (``lineplot`` means, ``kdeplot``, ``regplot`` fits)
494
+ - fill-between bands → role ``area`` (confidence / error bands)
495
+ - scatter collections → role ``point`` (``scatterplot`` / ``regplot`` — per-point addressable)
496
+ - bar containers → role ``bar`` (``barplot`` / ``countplot`` / ``histplot``)
497
+ - capped/horizontal bar errors → role ``errorbar`` (joined by categorical position)
498
+
499
+ The categorical kinds (``plot="boxplot"``, ``"violinplot"``, ``"stripplot"``, ``"swarmplot"``,
500
+ ``"pointplot"``) are named from seaborn's fixed drawing order: without ``hue`` every category is
501
+ a series (``a.box``, ``a.whisker``, ``a.points``, …); with ``hue`` every hue level is a series
502
+ and each category a named part of it (``p.a`` for the box, ``p.a-whisker``, ``p.a.point.k``).
503
+ A strip / swarm / scatter with hue levels mixed inside one collection is split by the frame
504
+ rows each level owns (``data=`` is needed for that), the collection staying one artist.
505
+ ``barplot(x=g, hue=g)`` is named from ``hue=`` even though seaborn draws no legend for it.
506
+
507
+ Seaborn's empty legend-proxy lines are removed (they draw nothing; the legend keeps its
508
+ own handles). Artists already tagged are skipped, so this composes with the ``fp.*``
509
+ helpers and :func:`tag`. Anything it cannot *confidently* pair with a series name is
510
+ left alone — ``save()``'s orphan sweep still makes it addressable as ``extra.*`` — and a
511
+ call that tags nothing warns.
512
+
513
+ Returns ``{series_name: [roles tagged]}`` so you can see exactly what got named.
514
+ """
515
+ from matplotlib.collections import PathCollection, PolyCollection
516
+ from matplotlib.container import BarContainer
517
+ from . import seaborn_adapters as _sb
518
+
519
+ reg = _tagger.registry_for(ax.figure)
520
+ already = {id(a) for m in reg.marks for a in m.artists}
521
+ frame = _sb.Frame(data, x, y, hue, order, hue_order) if data is not None and (x is not None or y is not None) else None
522
+
523
+ legend = ax.get_legend()
524
+ legend_texts = {t.get_text() for t in legend.get_texts()} if legend is not None else set()
525
+ # seaborn appends empty proxy lines used only to build its legend — drop them so the orphan
526
+ # sweep doesn't dutifully tag invisible leftovers. Only seaborn's own are removed: an empty
527
+ # line is a proxy when its label is private (``_…``) or is one of the legend's entries; an
528
+ # empty line the user drew with a public label of their own is left alone.
529
+ for ln in [ln for ln in ax.lines if len(ln.get_xdata()) == 0]:
530
+ lbl = str(ln.get_label())
531
+ if lbl.startswith("_") or lbl in legend_texts:
532
+ ln.remove()
533
+
534
+ legend_names = [t.get_text() for t in legend.get_texts()] if legend is not None else []
535
+ hues = None # the hue levels, in seaborn's order, when the call says there is a hue
536
+ if frame is not None and frame.hues is not None:
537
+ hues = frame.hues
538
+ elif hue_order is not None:
539
+ hues = list(hue_order)
540
+ elif hue is not None and legend_names:
541
+ hues = legend_names
542
+ if series is not None:
543
+ names = [str(s) for s in series]
544
+ elif hues is not None:
545
+ names = [str(h) for h in hues]
546
+ elif legend_names:
547
+ names = legend_names
548
+ else:
549
+ # a FacetGrid facet has no legend of its own: its title names the facet
550
+ names = [ax.get_ylabel() or ax.get_title() or "panel"]
551
+
552
+ # Seaborn deliberately reverses hue iteration for distribution plots.
553
+ # A legend does not encode this provenance. Require the plot kind for a
554
+ # multi-hue adapter instead of inferring identity from color or geometry.
555
+ if plot not in (None, "lineplot", "scatterplot", "barplot", "countplot", "histplot", "kdeplot", "regplot", "heatmap") + _sb.CATEGORICAL_KINDS:
556
+ raise ValueError("unsupported seaborn plot kind")
557
+ tagged: dict = {}
558
+ if plot in ("heatmap", "histplot", "kdeplot"):
559
+ # colour-mapped fields: sns.heatmap's mesh, a bivariate histplot's mesh, a bivariate
560
+ # kdeplot's contour set — each becomes an fp.heatmap / fp.contour-style field mark whose
561
+ # colour scale is recorded and whose colour key links (the seaborn call has already drawn,
562
+ # so recipe colour controls are recorded for the editor but cannot be applied on a rerun)
563
+ _tag_seaborn_fields(ax, reg, already, [str(s) for s in series] if series is not None else None, tagged)
564
+ if plot == "heatmap":
565
+ return tagged
566
+ if len(names) > 1 and series is None and plot is None:
567
+ warnings.warn("tag_seaborn: multi-hue identity needs plot='lineplot', 'histplot', "
568
+ "'barplot', etc.; ambiguous artists remain addressable as extras", stacklevel=2)
569
+ return {}
570
+ if plot in ("histplot", "kdeplot") and series is None:
571
+ names.reverse()
572
+
573
+ def _record(name, role):
574
+ tagged.setdefault(str(name), []).append(role)
575
+
576
+ bar_context = plot in ("barplot", "countplot") or bool(ax.containers)
577
+
578
+ def _is_segment(ln):
579
+ """A loose uncertainty line seaborn drew for a bar: axis-aligned two-point runs only.
580
+
581
+ seaborn draws an error bar as ``[x, x] × [lo, hi]`` and, with ``capsize``, joins the
582
+ two caps to it through NaN breaks (``cap, NaN, bar, NaN, cap``). Every finite run of such
583
+ a line has exactly two points sharing an x (vertical bars) or a y (horizontal bars).
584
+ Anything else — a KDE curve over a histogram, a fit line, a lineplot mean — is a data
585
+ curve, whatever else sits on the axes.
586
+ """
587
+ if not bar_context:
588
+ return False
589
+ x, y = (np.asarray(v, dtype=float) for v in ln.get_data(orig=False))
590
+ finite = np.isfinite(x) & np.isfinite(y)
591
+ if not finite.any():
592
+ return False
593
+ runs, start = [], None
594
+ for i, ok in enumerate(list(finite) + [False]):
595
+ if ok and start is None:
596
+ start = i
597
+ elif not ok and start is not None:
598
+ runs.append((start, i))
599
+ start = None
600
+ for a, b in runs:
601
+ if b - a != 2:
602
+ return False
603
+ if not (abs(x[a] - x[a + 1]) <= 1e-12 or abs(y[a] - y[a + 1]) <= 1e-12):
604
+ return False
605
+ return True
606
+
607
+
608
+ from .colors import categories as _categories
609
+
610
+ def _pin(name, artist, setter):
611
+ """A hue level pinned in fp.colors.categories (or a recipe series colour) recolours its artist."""
612
+ colour = _series_color_override(name)
613
+ if colour is None and _categories.is_pinned(name):
614
+ colour = _categories.get(name)
615
+ if colour is not None:
616
+ setter(artist, colour)
617
+
618
+ if plot in _sb.CATEGORICAL_KINDS:
619
+ if frame is not None:
620
+ categories, orient = frame.categories, frame.orient
621
+ else:
622
+ orient = "v" if ax.xaxis.get_ticklabels() and any(t.get_text() for t in ax.xaxis.get_ticklabels()) else "h"
623
+ categories = list(order) if order is not None else _sb.tick_categories(ax, orient)
624
+ hue_levels = hues if (hue is not None or hue_order is not None or (frame is not None and frame.hues)) else (
625
+ legend_names if legend_names and series is None else None)
626
+ _sb.tag_categorical(ax, plot, frame=frame, categories=categories, hues=hue_levels, reg=reg, already=already,
627
+ tagged=tagged, pin=_pin)
628
+ if not tagged:
629
+ warnings.warn(f"tag_seaborn: nothing tagged for plot={plot!r}; artists remain addressable as extras", stacklevel=2)
630
+ return tagged
631
+
632
+ if plot == "scatterplot" and frame is not None and frame.hues is not None:
633
+ colls = [c for c in ax.collections if id(c) not in already and isinstance(c, PathCollection) and not isinstance(c, PolyCollection)]
634
+ if len(colls) == 1 and _sb.split_scatter(reg, colls[0], frame, tagged):
635
+ return tagged
636
+
637
+ # data curves (one per hue level) → line
638
+ curves = [ln for ln in ax.lines if id(ln) not in already and len(ln.get_xdata()) and not _is_segment(ln)]
639
+ if curves and len(curves) == len(names):
640
+ for name, ln in zip(names, curves):
641
+ _pin(name, ln, lambda a, c: a.set_color(c))
642
+ gx, gy = ln.get_data()
643
+ reg.add(Mark(role="line", series=name, kind="line", x=_list(gx), y=_list(gy), live_data=True, label=name, artists=[ln]))
644
+ _record(name, "line")
645
+
646
+ # fill-between bands (one per hue level) → area
647
+ bands = [c for c in ax.collections if id(c) not in already and isinstance(c, PolyCollection)]
648
+ if bands and len(bands) == len(names):
649
+ for name, band in zip(names, bands):
650
+ reg.add(Mark(role="area", series=name, kind="area", label=name, artists=[band]))
651
+ _record(name, "area")
652
+
653
+ # scatter collections → point (per-point addressable, with data values from the offsets)
654
+ pts = [
655
+ c for c in ax.collections
656
+ if id(c) not in already and isinstance(c, PathCollection) and not isinstance(c, PolyCollection)
657
+ ]
658
+ if pts and len(pts) == len(names):
659
+ pairs = list(zip(names, pts))
660
+ elif len(pts) == 1: # scatterplot(hue=) fuses all hues into one collection
661
+ pairs = [(ax.get_ylabel() or "points", pts[0])]
662
+ else:
663
+ pairs = []
664
+ for name, coll in pairs:
665
+ off = coll.get_offsets()
666
+ x, y = [float(v) for v in off[:, 0]], [float(v) for v in off[:, 1]]
667
+ reg.add(Mark(role="point", series=name, kind="scatter", x=x, y=y, artists=[coll], indexed=True, live_data=True))
668
+ _record(name, "point")
669
+
670
+ # bar containers (one per hue level) → bar, with bar centers/heights as the data
671
+ bars = [c for c in getattr(ax, "containers", []) if isinstance(c, BarContainer)]
672
+ bar_centers: list = [] # (name, {x center}) for the error-bar join below
673
+ if bars and len(bars) == len(names):
674
+ for name, cont in zip(names, bars):
675
+ patches = [p for p in cont.patches if id(p) not in already]
676
+ if not patches:
677
+ continue
678
+ for patch in patches:
679
+ _pin(name, patch, lambda a, c: a.set_facecolor(c))
680
+ orientation = getattr(cont, "orientation", "vertical")
681
+ cx, cy, meta = _data.bar_data(patches, orientation)
682
+ reg.add(Mark(role="bar", series=name, kind="bar", x=cx, y=cy, label=name,
683
+ live_data=True, artists=patches, indexed=True, data={"bar": meta}))
684
+ _record(name, "bar")
685
+ bar_centers.append((name, orientation, {round(v, 9) for v in (cy if orientation == "horizontal" else cx)}))
686
+
687
+ # seaborn draws bar errors as loose 2-point vertical lines — join each to its bar
688
+ # by x position (an exact join on coordinates seaborn itself set, not a guess).
689
+ segs = [ln for ln in ax.lines if id(ln) not in already and len(ln.get_xdata()) and _is_segment(ln)]
690
+ for ln in segs:
691
+ matches = []
692
+ for name, orientation, centers in bar_centers:
693
+ positions = np.asarray(ln.get_ydata() if orientation == "horizontal" else ln.get_xdata(), dtype=float)
694
+ center = round(float(np.nanmedian(positions)), 9)
695
+ if center in centers:
696
+ matches.append(name)
697
+ if len(matches) == 1:
698
+ name = matches[0]
699
+ reg.add(Mark(role="errorbar", series=name, kind="errorbar", artists=[ln]))
700
+ _record(name, "errorbar")
701
+
702
+ if not tagged:
703
+ warnings.warn("tag_seaborn: nothing tagged; pass plot= (and data=, x=, y=, hue= for hue splits) — "
704
+ "the artists remain addressable as extras", stacklevel=2)
705
+ return tagged
706
+
707
+
708
+ def _tag_seaborn_fields(ax, reg, already, names, tagged) -> None:
709
+ from matplotlib.collections import QuadMesh
710
+ from matplotlib.contour import ContourSet
711
+
712
+ from . import fields as _fields
713
+ from . import raster as _raster
714
+
715
+ mappables = [c for c in ax.collections if id(c) not in already
716
+ and (isinstance(c, QuadMesh) or (isinstance(c, ContourSet) and c.get_array() is not None))]
717
+ for k, art in enumerate(mappables):
718
+ name = names[k] if names is not None and k < len(names) else ("heatmap" if k == 0 else f"heatmap-{k}")
719
+ key, _legacy = _fields.control_key(ax, name)
720
+ if isinstance(art, QuadMesh):
721
+ arr = np.ma.asarray(art.get_array())
722
+ shape = list(arr.shape) if arr.ndim == 2 else [int(arr.size), 1]
723
+ config = {"kind": "heatmap", "shape": shape, "includeValues": False, "controlKey": key}
724
+ reg.add(Mark(role="x-heatmap", series=name, kind="heatmap", artists=[art],
725
+ data={"field_config": config, "field_artist": art,
726
+ "cells": int(arr.size) <= _raster.DEFAULT_THRESHOLD}))
727
+ tagged.setdefault(name, []).append("x-heatmap")
728
+ else:
729
+ config = {"kind": "contourf" if art.filled else "contour", "levels": _data.values(art.levels),
730
+ "extend": art.extend, "controlKey": key}
731
+ reg.add(Mark(role="x-contourf" if art.filled else "x-contour", series=name, kind=config["kind"],
732
+ artists=[art], axes=ax,
733
+ data={"field_config": config, "field_artist": art, "contour_paths": True}))
734
+ tagged.setdefault(name, []).append(config["kind"])
735
+ already.add(id(art))
736
+
737
+
738
+ # ---------------------------------------------------------------------------
739
+ # first-class overlays
740
+ # ---------------------------------------------------------------------------
741
+ def significance_bracket(ax, *, x0, x1, y, label, between=None, p=None, name=None, height=None,
742
+ color=None, text_kw=None, stats=None, **kw):
743
+ """A p-value bracket (spec §5: deliberately first-class — ubiquitous in science).
744
+
745
+ ``color`` paints both the bracket line and its label (default: the theme's text colour,
746
+ so a bracket reads on dark grounds too); ``text_kw`` are extra ``Text`` properties for the
747
+ label (``fontsize``, ``fontweight``, …). ``height`` is the tip height in data units
748
+ (default: 3 % of the y range, or a 6 % step on a log axis). ``stats`` is the provenance of
749
+ the label — the test, statistic, p-values, effect size — recorded on the manifest overlay
750
+ (:func:`fluxplot.brackets` fills it from an ``fp.stats`` row).
751
+ """
752
+ reg = _tagger.registry_for(ax.figure)
753
+ idx = reg.next_overlay_index("significance-bracket")
754
+ if name is None:
755
+ name = str(idx)
756
+ if ax.get_yscale() == "log" and y <= 0:
757
+ raise ValueError(f"significance_bracket: y must be positive on a log axis (got y={y!r})")
758
+ if height is None:
759
+ if ax.get_yscale() == "log":
760
+ ytop = y * 1.06
761
+ else:
762
+ lo, hi = ax.get_ylim()
763
+ ytop = y + (hi - lo) * 0.03
764
+ else:
765
+ ytop = y + height
766
+ themed = color is None
767
+ if themed:
768
+ color = matplotlib.rcParams["text.color"]
769
+ (br,) = ax.plot([x0, x0, x1, x1], [y, ytop, ytop, y], color=color,
770
+ linewidth=kw.pop("linewidth", 1.0), **kw)
771
+ txt = ax.text((x0 + x1) / 2.0, ytop, label, ha="center", va="bottom", color=color,
772
+ **(text_kw or {}))
773
+ data = {"label": label, "index": idx, "label_artist": txt}
774
+ if themed: # drawn in the theme's ink: say so outright (B1), no colour comparison needed
775
+ data["ink"] = {"stroke": "ink"}
776
+ data["ink_label"] = {"fill": "ink"}
777
+ if between is not None:
778
+ data["between"] = list(between)
779
+ if p is not None:
780
+ data["p"] = p
781
+ if stats:
782
+ data["stats"] = dict(stats)
783
+ reg.add(Mark(role="significance-bracket", series=None, name=name, artists=[br], data=data))
784
+ br._fluxplot_label = txt
785
+ return br
786
+
787
+
788
+ def reference_line(ax, *, y=None, x=None, name, **kw):
789
+ reg = _tagger.registry_for(ax.figure)
790
+ if y is not None:
791
+ ln = ax.axhline(y, **kw)
792
+ elif x is not None:
793
+ ln = ax.axvline(x, **kw)
794
+ else:
795
+ raise ValueError("reference_line needs x= or y=")
796
+ reg.add(Mark(role="reference-line", series=None, name=name, artists=[ln], data={"x": x, "y": y}))
797
+ return ln
798
+
799
+
800
+ def annotation(ax, *, name, text, **kw):
801
+ reg = _tagger.registry_for(ax.figure)
802
+ art = ax.annotate(text, **kw)
803
+ reg.add(Mark(role="annotation", series=None, name=name, artists=[art], data={"text": text}))
804
+ return art
805
+
806
+
807
+ # ---------------------------------------------------------------------------
808
+ # the export
809
+ # ---------------------------------------------------------------------------
810
+ @dataclass
811
+ class SaveResult:
812
+ svg: str
813
+ manifest: str
814
+ recipe: str
815
+ warnings: list = field(default_factory=list)
816
+ #: True when FLUXPLOT_ONLY filtered this plot out — nothing was written.
817
+ skipped: bool = False
818
+ #: gids of the layers auto-rasterized on the way out (see ``raster.py``). Empty on an
819
+ #: ordinary save; empty too under ``force_vectors=True``, where the heavy layers are
820
+ #: instead reported through :attr:`warnings`.
821
+ rasterized: list = field(default_factory=list)
822
+
823
+
824
+ def _warn_log_zero_anchors(plot_axes, plot_name: str) -> list:
825
+ """Warn — at generation time, where the fix belongs — when a bar/rect on a
826
+ log-scaled axis extends to data ≤ 0. matplotlib serializes that anchor as a
827
+ huge off-canvas SVG coordinate (−50k…−200k in a ~400-unit canvas): the plot
828
+ renders standalone, but downstream compositors/rasterizers can crash on it
829
+ (flux `validate-plot` now rejects it). The remedy is one line in the plot
830
+ script: anchor at a positive value (barh: left=1, bar: bottom=1)."""
831
+ from matplotlib.patches import Rectangle
832
+
833
+ out = []
834
+ for ax in plot_axes:
835
+ logx = ax.get_xscale() == "log"
836
+ logy = ax.get_yscale() == "log"
837
+ if not (logx or logy):
838
+ continue
839
+ bad = 0
840
+ for pt in ax.patches:
841
+ if not isinstance(pt, Rectangle):
842
+ continue
843
+ if logx and min(pt.get_x(), pt.get_x() + pt.get_width()) <= 0:
844
+ bad += 1
845
+ elif logy and min(pt.get_y(), pt.get_y() + pt.get_height()) <= 0:
846
+ bad += 1
847
+ if bad:
848
+ msg = (
849
+ f"{plot_name}: {bad} bar(s)/rect(s) on a log-scaled axis extend to data <= 0; "
850
+ "matplotlib serializes those anchors as huge off-canvas coordinates that can "
851
+ "crash downstream renderers. Anchor at a positive value instead "
852
+ "(barh: left=1, width=count-1; bar: bottom=1, height=count-1)."
853
+ )
854
+ warnings.warn(msg, UserWarning, stacklevel=3)
855
+ out.append(msg)
856
+ return out
857
+
858
+
859
+ def _infer_plot_type(reg) -> str:
860
+ kinds = [m.kind for m in reg.marks if m.kind]
861
+ for k in ("glowbar", "fluxbox", "hexmatrix", "image", "regression", "kde", "line", "scatter", "bar", "errorbar", "area", "box", "violin", "heatmap", "contour", "contourf", "surface"):
862
+ if k in kinds:
863
+ return k
864
+ return "plot"
865
+
866
+
867
+ def _validate(manifest_obj, recipe_obj) -> None:
868
+ try:
869
+ import jsonschema
870
+ from importlib.resources import files
871
+
872
+ sch = files("fluxplot").joinpath("schemas")
873
+ mschema = json.loads((sch / "manifest.schema.json").read_text())
874
+ rschema = json.loads((sch / "recipe.schema.json").read_text())
875
+ except (FileNotFoundError, ModuleNotFoundError):
876
+ return
877
+ jsonschema.validate(manifest_obj, mschema)
878
+ jsonschema.validate(recipe_obj, rschema)
879
+
880
+
881
+ def _write_staged(files) -> None:
882
+ """Write the sidecar triplet safely: stage EVERY file under a unique temporary name in its
883
+ destination directory (flush + fsync), then commit each with ``os.replace`` in the given
884
+ dependency order. A failure while staging leaves the destination completely untouched, and
885
+ a watcher can never observe a partially written individual file. The three replacements are
886
+ still not globally atomic — the manifest's ``artifact.svgSha256`` is the cross-file commit
887
+ marker a consumer verifies (plan §5). Existing destination permissions are preserved.
888
+ """
889
+ staged: list[tuple[str, str]] = []
890
+ try:
891
+ for path, data in files:
892
+ tmp = f"{path}.{os.getpid()}.staging"
893
+ with open(tmp, "wb") as f:
894
+ f.write(data)
895
+ f.flush()
896
+ os.fsync(f.fileno())
897
+ try:
898
+ os.chmod(tmp, stat.S_IMODE(os.stat(path).st_mode))
899
+ except OSError:
900
+ pass # new file → default creation mode
901
+ staged.append((tmp, path))
902
+ for tmp, path in staged:
903
+ os.replace(tmp, path)
904
+ finally:
905
+ for tmp, _ in staged: # clean whatever a failure left behind (replaced tmps are gone)
906
+ try:
907
+ os.unlink(tmp)
908
+ except OSError:
909
+ pass
910
+
911
+
912
+ def _save(
913
+ fig,
914
+ path,
915
+ *,
916
+ recipe=None,
917
+ validate=True,
918
+ force_vectors=False,
919
+ raster_threshold=None,
920
+ raster_dpi=None,
921
+ theme_vars=False,
922
+ lint="off",
923
+ _now=None,
924
+ _registry=None,
925
+ ) -> SaveResult:
926
+ """Emit ``<path>.svg`` + ``<path>.fluxplot.json`` + ``<path>.recipe.json`` for ``fig``.
927
+
928
+ ``fig`` may also be a 3D scene from :func:`fp.scene3d <fluxplot.scene3d>`: it then writes
929
+ ``<path>.glb`` + ``<path>.fluxplot.json`` (the scene3d manifest) + ``<path>.recipe.json``,
930
+ always at full mesh resolution (``preview_max_faces`` only affects notebook previews).
931
+ ``path`` is a stem or ends in ``.glb``. ``recipe``, ``validate`` and ``FLUXPLOT_ONLY``
932
+ behave as below; the SVG rasterization options do not apply.
933
+
934
+ ``recipe`` controls the provenance sidecar:
935
+
936
+ - ``None`` (default) — automatic: the producing script is discovered from the running
937
+ interpreter when that is safe and exact, making the recipe rerunnable with zero ceremony.
938
+ - ``False`` — explicitly suppress discovery (notebooks, generated figures, privacy).
939
+ - ``dict`` — explicit fields (``script``/``command``/``params``/``inputs``) always win;
940
+ an inferred script only fills a missing ``script``.
941
+
942
+ **Heavy layers are rasterized by default.** An artist that draws more than
943
+ ``raster_threshold`` primitives (default 800) — a ``LineCollection`` of per-edge
944
+ segments, a 10k-point ``scatter`` — would otherwise become that many live SVG nodes and
945
+ make any editor that inlines the plot unusable. Such layers are rendered to a single
946
+ embedded ``<image>`` at ``raster_dpi`` (default 600) while axes, ticks, tick labels,
947
+ legend, annotations and every lighter artist stay fully vector, and ``save`` says on
948
+ stderr exactly what it rasterized. The layers keep their ids, ``data-role``/``data-series``
949
+ and manifest entries, so they remain addressable as a whole; only *per-point* ids are
950
+ unavailable (a rasterized cloud has no per-point nodes). ``SaveResult.rasterized`` lists
951
+ what was rasterized.
952
+
953
+ Set ``force_vectors=True`` (or the ``FLUXPLOT_FORCE_VECTORS`` environment variable) to
954
+ keep everything vector — a per-artist ``set_rasterized(False)`` does *not* override the
955
+ safety default, because that is matplotlib's silent factory setting rather than a
956
+ considered choice. Under ``force_vectors`` the heavy layers are still reported, as a
957
+ warning naming them and their node cost.
958
+
959
+ **Themes.** The manifest records ``style = {"theme", "tokens"}`` — the scaffold colours in
960
+ force at save (ink, label, tick, axis, grid, plot, paper) and the ``fx.use_*`` theme they
961
+ came from — and every scaffold element painted with one of them carries ``data-ink-fill`` /
962
+ ``data-ink-stroke`` naming the token, so a consumer can restyle the furniture to its own
963
+ theme without touching a data colour. ``theme_vars=True`` additionally rewrites those paints
964
+ to ``var(--fx-<token>, <hex>)``, which a CSS-aware host can drive directly (off by default:
965
+ the fallback form is honoured by browsers and rsvg, still to be checked in Illustrator /
966
+ Inkscape). A rerun with ``FLUX_PARAMS={"__fluxplot__": {"theme": "dark"}}`` makes every
967
+ ``fx.use_*`` call apply that theme instead.
968
+
969
+ **Accessibility lint** (``lint="warn"`` / ``"error"``, default ``"off"``): the tagged series'
970
+ colours and the text inks are checked against one another and the ground
971
+ (:mod:`fluxplot.colorcheck` — colour-vision deficiency, greyscale, WCAG contrast). Findings
972
+ land in ``SaveResult.warnings`` and in the manifest's ``quality.color``; ``"error"`` refuses
973
+ the save with them.
974
+
975
+ **Targeted reruns** (``FLUXPLOT_ONLY``): a figure-level script that saves
976
+ several plots can be re-run for ONE of them — set ``FLUXPLOT_ONLY`` to a
977
+ comma-separated list of plot names (fnmatch patterns work: ``fig2*``) and
978
+ every non-matching ``save`` becomes a no-op (nothing written, siblings
979
+ untouched on disk). ``flux rerun-plot <recipe> --only`` sets this for you.
980
+ """
981
+ keep_vectors = bool(force_vectors) or _env_flag("FLUXPLOT_FORCE_VECTORS")
982
+ threshold = _raster.DEFAULT_THRESHOLD if raster_threshold is None else int(raster_threshold)
983
+ raster_dpi = _raster.DEFAULT_DPI if raster_dpi is None else int(raster_dpi)
984
+
985
+ base, _ext = os.path.splitext(path)
986
+ plot_name = os.path.basename(base)
987
+ svg_path = base + ".svg"
988
+ manifest_path = base + ".fluxplot.json"
989
+ recipe_path = base + ".recipe.json"
990
+ svg_filename = os.path.basename(svg_path)
991
+ manifest_filename = os.path.basename(manifest_path)
992
+
993
+
994
+ reg = _registry if _registry is not None else _tagger.snapshot(fig)
995
+ alloc = _ids.IdAllocator()
996
+ from . import fields as _fields
997
+
998
+ # a value raster (fp.heatmap(value_raster=True)) is named before capture, so the colour scale
999
+ # can point at the sidecar it travels with
1000
+ for m in reg.marks:
1001
+ if m.data.get('value_raster') is not None and m.data.get('field_config'):
1002
+ m.data['value_raster']['filename'] = f"{plot_name}.{m.data['field_config']['controlKey']}.values.json"
1003
+ for vr in m.data.get('value_rasters') or []: # fp.image: one sidecar per channel
1004
+ vr['filename'] = f"{plot_name}.{vr['key']}.values.json"
1005
+ for rec in m.data.get('color_scales') or []:
1006
+ if rec['id'] == vr['key']:
1007
+ rec['valueRaster'] = vr['filename']
1008
+
1009
+ panels = _panels.plan(fig)
1010
+ # figure-scope artists first (suptitle, fig.legend, fig.text): unprefixed, tagged once
1011
+ figure_guides = _tagger.autotag_figure(fig, alloc, first_prefix=panels[0].prefix if panels else "")
1012
+ promo_warnings, guides_by_panel, axes_capture, scales_by_panel = [], [], [], []
1013
+ registered = {id(m) for m in reg.marks}
1014
+ for i, panel in enumerate(panels):
1015
+ ax = panel.axes
1016
+ twin_axis = {id(t): which for t, which in panel.twins}
1017
+ # Colorbar marks belong to the plot whose mappable produced their key; a twin's marks
1018
+ # belong to the primary's panel and say which value axis they read (axis: "y2").
1019
+ members = []
1020
+ for m in reg.marks:
1021
+ owner = getattr(m.axes, "_fluxplot_owner_axes", m.axes)
1022
+ if owner is ax or id(owner) in twin_axis or (owner is None and i == 0):
1023
+ m._panel_axes = ax
1024
+ members.append(m)
1025
+ sub = _tagger.Registry()
1026
+ for m in members:
1027
+ sub.add(m)
1028
+ promo_warnings.extend(_autotag.promote_labeled(SimpleNamespace(axes=panel.all_axes), sub))
1029
+ for m in sub.marks:
1030
+ m._panel_axes = ax
1031
+ if id(m.axes) in twin_axis and m.series is not None:
1032
+ m.data["axis"] = twin_axis[id(m.axes)]
1033
+ _data.refresh(m)
1034
+ if id(m) not in registered:
1035
+ reg.marks.append(m)
1036
+ registered.add(id(m))
1037
+ scoped = _panels.ScopedAllocator(alloc, panel.prefix)
1038
+ _tagger.resolve_gids(sub, scoped)
1039
+ ax.set_gid(panel.svg_id)
1040
+ local = _tagger.autotag_scaffold(ax, scoped)
1041
+ for twin, which in panel.twins:
1042
+ twin.set_gid(panel.svg_id + "." + which)
1043
+ local += _tagger.autotag_scaffold(twin, scoped, secondary=which)
1044
+ local += _fields.colorbar_guides(fig, ax, scoped)
1045
+ guides_by_panel.append(local)
1046
+ # raw colour-mapped artists (an imshow, a pcolormesh, a scatter with c=) the sweep just
1047
+ # named get an anonymous colour scale: no series, but a linked key and editable colours
1048
+ scales_by_panel.append([sc for a in panel.all_axes for sc in _fields.anonymous_scales(a, reg)])
1049
+ axes_capture.append({"id": "plot-area", "svgId": "plot-area", **_capture.capture_axes(ax, fig, panel.twins)})
1050
+ plot_axes = [a for p in panels for a in p.all_axes]
1051
+ guides = [g for local in guides_by_panel for g in local] + figure_guides
1052
+ geometry_warnings = _warn_log_zero_anchors(plot_axes, plot_name)
1053
+
1054
+ # accessibility lint (B3): findings travel with the plot; "error" refuses it outright
1055
+ lint_findings, lint_warnings = [], []
1056
+ if lint not in ("off", "warn", "error"):
1057
+ raise ValueError("lint must be 'off', 'warn' or 'error'")
1058
+ if lint != "off":
1059
+ from .colorcheck import check_figure
1060
+ lint_findings = [f.as_dict() for f in check_figure(fig)]
1061
+ lint_warnings = [f"{plot_name}: colour lint: {f['message']}" for f in lint_findings]
1062
+ if lint == "error" and lint_findings:
1063
+ raise ValueError("colour lint failed:\n " + "\n ".join(f["message"] for f in lint_findings))
1064
+ for msg in lint_warnings:
1065
+ warnings.warn(msg, UserWarning, stacklevel=3)
1066
+
1067
+ # 3b. auto-rasterize pathologically heavy layers — the safety default. A LineCollection of
1068
+ # per-edge segments or a 10k-point scatter becomes one <image> instead of 10^4-10^5 SVG
1069
+ # nodes, while axes/ticks/labels/legend stay vector (see raster.py). Planned AFTER the
1070
+ # scaffold sweep so untagged-but-heavy artists are named first, and applied around the
1071
+ # render only — the user's figure is handed back exactly as they built it.
1072
+ per_artist = {id(a): int(m.data['raster_threshold']) for m in reg.marks
1073
+ if m.data.get('raster_threshold') is not None for a in m.artists}
1074
+ heavy = _raster.plan(fig, threshold, per_artist=per_artist)
1075
+ raster_items = [] if keep_vectors else heavy
1076
+ raster_warnings = []
1077
+ # 4. render deterministically (hashsalt derived from the plot name)
1078
+ with _raster.rasterizing(fig, heavy, force_vectors=keep_vectors), _fields.vector_colorbars(fig):
1079
+ svg_bytes = _render.render_svg(
1080
+ fig,
1081
+ hashsalt=plot_name or "fluxplot",
1082
+ dpi=raster_dpi if raster_items else None,
1083
+ )
1084
+
1085
+ # 5. inject data-* + canonicalize
1086
+ plot_type = _infer_plot_type(reg)
1087
+ extra_scales = [sc for local in scales_by_panel for sc in local]
1088
+ from . import style as _style
1089
+ style_record = _style.theme_record()
1090
+ out_svg, post_warnings, present = _postprocess.postprocess(
1091
+ svg_bytes, reg, guides, plot_type, raster_items=raster_items, check_ids=validate,
1092
+ extra_scales=extra_scales, style_tokens=style_record["tokens"], theme_vars=theme_vars,
1093
+ )
1094
+ rendered_heavy = [it for it in heavy if it.gid in present]
1095
+ if rendered_heavy:
1096
+ note = _raster.describe(rendered_heavy, plot_name=plot_name, dpi=raster_dpi,
1097
+ rasterized=not keep_vectors)
1098
+ raster_warnings.append(note)
1099
+ print(note, file=sys.stderr)
1100
+ persistent = _tagger.registry_for(fig)
1101
+ tag_warnings, persistent.warnings = list(persistent.warnings), []
1102
+ all_warnings = promo_warnings + tag_warnings + geometry_warnings + lint_warnings + raster_warnings + post_warnings
1103
+
1104
+ # 6. assemble manifest + recipe. Drop scaffold guides matplotlib culled at draw
1105
+ # (boundary ticks/gridlines, empty axis titles) so the manifest references only
1106
+ # parts that exist in the SVG — keeps the parts tree / group members honest.
1107
+ kept_guides = [[g for g in local if g.gid in present or (g.virtual and any(other.axis == g.axis and other.gid in present for other in local))]
1108
+ for local in guides_by_panel]
1109
+ rasterized_gids = {it.gid for it in raster_items if it.gid and it.gid in present}
1110
+ man = _panels.manifest(
1111
+ fig, reg, kept_guides, panels, axes_capture, present, rasterized_gids,
1112
+ extra_scales_by_panel=scales_by_panel, figure_guides=[g for g in figure_guides if g.gid in present],
1113
+ style=style_record,
1114
+ quality={"color": lint_findings} if lint != "off" else None,
1115
+ plot_type=plot_type, svg_filename=svg_filename, spec_version=SPEC_VERSION,
1116
+ fluxplot_version=__version__, mpl_version=matplotlib.__version__,
1117
+ svg_sha256=hashlib.sha256(out_svg).hexdigest(),
1118
+ )
1119
+ rec = _recipe.build_recipe(
1120
+ recipe, plot_name=plot_name, svg_filename=svg_filename,
1121
+ manifest_filename=manifest_filename, spec_version=SPEC_VERSION,
1122
+ recipe_dir=os.path.dirname(os.path.abspath(svg_path)), now=_now,
1123
+ )
1124
+ # the complete current state of every colour scale (colorscale.controls_state): what the Flux
1125
+ # editor starts from, and what a rerun replays byte for byte
1126
+ from .colorscale import controls_state
1127
+ controls = {m.data['field']['controlKey']: controls_state(m.data['field'])
1128
+ for m in reg.marks if m.data.get('field')}
1129
+ for m in reg.marks: # fp.image: one control per channel
1130
+ controls.update(m.data.get('color_controls') or {})
1131
+ if style_record["theme"] is not None: # the theme is a recipe control too (__fluxplot__.theme)
1132
+ controls["theme"] = style_record["theme"]
1133
+ if controls:
1134
+ rec['params'] = {**rec['params'], '__fluxplot__': controls}
1135
+ if validate:
1136
+ from .integrity import validate_references
1137
+ validate_references(man, present)
1138
+ _validate(man, rec)
1139
+
1140
+ # 7. stage all three, then commit in dependency order (SVG → manifest → recipe): the
1141
+ # manifest checksum is the commit marker a consumer verifies against the SVG it sees.
1142
+ out_dir = os.path.dirname(os.path.abspath(svg_path))
1143
+ os.makedirs(out_dir, exist_ok=True)
1144
+ # value rasters sit between the SVG and the manifest that references them
1145
+ value_files = [(os.path.join(out_dir, m.data['value_raster']['filename']),
1146
+ _cjson.dumps(m.data['value_raster']['payload']).encode("utf-8"))
1147
+ for m in reg.marks if m.data.get('value_raster') and m.data['value_raster'].get('payload')]
1148
+ value_files += [(os.path.join(out_dir, vr['filename']), _cjson.dumps(vr['payload']).encode("utf-8"))
1149
+ for m in reg.marks for vr in (m.data.get('value_rasters') or [])]
1150
+ _write_staged(
1151
+ [
1152
+ (svg_path, out_svg),
1153
+ *value_files,
1154
+ (manifest_path, _cjson.dumps(man).encode("utf-8")),
1155
+ (recipe_path, _cjson.dumps(rec).encode("utf-8")),
1156
+ ]
1157
+ )
1158
+
1159
+ return SaveResult(
1160
+ svg=svg_path,
1161
+ manifest=manifest_path,
1162
+ recipe=recipe_path,
1163
+ warnings=all_warnings,
1164
+ rasterized=sorted(rasterized_gids),
1165
+ )
1166
+
1167
+
1168
+ def save(fig, path, *, recipe=None, validate=True, force_vectors=False,
1169
+ raster_threshold=None, raster_dpi=None, theme_vars=False, lint="off", _now=None) -> SaveResult:
1170
+ from .scene3d import Scene3D
1171
+ if isinstance(fig, Scene3D):
1172
+ from .scene3d_manifest import save_scene3d
1173
+ return save_scene3d(fig, path, recipe=recipe, validate=validate, _now=_now)
1174
+ base, _ext = os.path.splitext(path)
1175
+ plot_name = os.path.basename(base)
1176
+ svg_path, manifest_path, recipe_path = base + '.svg', base + '.fluxplot.json', base + '.recipe.json'
1177
+ only = os.environ.get("FLUXPLOT_ONLY", "").strip()
1178
+ if only:
1179
+ pats = [p.strip() for p in only.split(",") if p.strip()]
1180
+ if pats and not any(fnmatch.fnmatchcase(plot_name, p) for p in pats):
1181
+ print(f"fluxplot: skipped '{plot_name}' (FLUXPLOT_ONLY={only})", file=sys.stderr)
1182
+ return SaveResult(
1183
+ svg=svg_path, manifest=manifest_path, recipe=recipe_path,
1184
+ warnings=[f"skipped by FLUXPLOT_ONLY={only}"], skipped=True,
1185
+ )
1186
+
1187
+ from .fields import resolve_scales
1188
+ resolve_scales(fig) # shared scales take the union of their members' values before layout
1189
+ reg = _tagger.snapshot(fig)
1190
+ with _tagger.temporary_gids(fig, reg), _render.final_layout(fig):
1191
+ return _save(fig, path, recipe=recipe, validate=validate, force_vectors=force_vectors,
1192
+ raster_threshold=raster_threshold, raster_dpi=raster_dpi, theme_vars=theme_vars,
1193
+ lint=lint, _now=_now, _registry=reg)
1194
+
1195
+
1196
+ save.__doc__ = _save.__doc__