fluxplot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fluxplot/__init__.py +115 -0
- fluxplot/_fieldmap.py +97 -0
- fluxplot/_mesh_reduce.py +54 -0
- fluxplot/_scene3d_size.py +95 -0
- fluxplot/_viewer/THIRD-PARTY.txt +23 -0
- fluxplot/_viewer/flux-model3d-viewer.min.js +4221 -0
- fluxplot/_viewer/stamp.json +4 -0
- fluxplot/api.py +1196 -0
- fluxplot/autotag.py +164 -0
- fluxplot/base.mplstyle +0 -0
- fluxplot/brackets.py +242 -0
- fluxplot/canonical_json.py +23 -0
- fluxplot/capture.py +150 -0
- fluxplot/colorcheck.py +285 -0
- fluxplot/colors.py +727 -0
- fluxplot/colorscale.py +477 -0
- fluxplot/data.py +178 -0
- fluxplot/definitions/colormaps.json +1639 -0
- fluxplot/definitions/flexoki.tokens.json +2571 -0
- fluxplot/definitions/palettes.json +2547 -0
- fluxplot/descriptors.py +87 -0
- fluxplot/fields.py +611 -0
- fluxplot/fits.py +240 -0
- fluxplot/glb.py +84 -0
- fluxplot/ids.py +173 -0
- fluxplot/images.py +362 -0
- fluxplot/integrity.py +27 -0
- fluxplot/manifest.py +788 -0
- fluxplot/mesh3d.py +376 -0
- fluxplot/panels.py +284 -0
- fluxplot/postprocess.py +638 -0
- fluxplot/presets.py +66 -0
- fluxplot/provenance.py +177 -0
- fluxplot/raster.py +295 -0
- fluxplot/recipe.py +178 -0
- fluxplot/render.py +66 -0
- fluxplot/roles.py +147 -0
- fluxplot/scene3d.py +386 -0
- fluxplot/scene3d_manifest.py +112 -0
- fluxplot/scene3d_viewer.py +633 -0
- fluxplot/schemas/.gitkeep +0 -0
- fluxplot/schemas/manifest.schema.json +2479 -0
- fluxplot/schemas/recipe.schema.json +179 -0
- fluxplot/schemas/scene3d.schema.json +461 -0
- fluxplot/seaborn_adapters.py +323 -0
- fluxplot/signature_fluxplots/__init__.py +18 -0
- fluxplot/signature_fluxplots/_colour.py +412 -0
- fluxplot/signature_fluxplots/fluxbox.py +433 -0
- fluxplot/signature_fluxplots/glowbar.py +769 -0
- fluxplot/signature_fluxplots/hexmatrix.py +927 -0
- fluxplot/stats/__init__.py +63 -0
- fluxplot/stats/_common.py +196 -0
- fluxplot/stats/multi_group.py +443 -0
- fluxplot/stats/paired.py +209 -0
- fluxplot/stats/two_group.py +149 -0
- fluxplot/style.py +469 -0
- fluxplot/surface.py +487 -0
- fluxplot/surface3d.py +197 -0
- fluxplot/tagger.py +561 -0
- fluxplot/version.py +19 -0
- fluxplot-0.1.0.dist-info/METADATA +1199 -0
- fluxplot-0.1.0.dist-info/RECORD +65 -0
- fluxplot-0.1.0.dist-info/WHEEL +4 -0
- fluxplot-0.1.0.dist-info/licenses/LICENSE +21 -0
- fluxplot-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +472 -0
fluxplot/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__
|