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/tagger.py
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
"""The semantic registry + gid assignment + scaffold auto-tagging.
|
|
2
|
+
|
|
3
|
+
The registry maps a matplotlib ``Figure`` to the :class:`~fluxplot.descriptors.Mark`\\ s tagged on
|
|
4
|
+
it, via a ``WeakKeyDictionary`` (no monkeypatching of matplotlib; GC-safe — the registry dies with
|
|
5
|
+
the figure). ``resolve_gids`` turns Marks into deterministic SVG ids (``artist.set_gid``);
|
|
6
|
+
``autotag_scaffold`` names the axes/legend/title the user never has to tag by hand.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import weakref
|
|
11
|
+
from dataclasses import replace
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
|
|
14
|
+
from . import ids as _ids
|
|
15
|
+
from .descriptors import GuideTag, Mark, artist_kind
|
|
16
|
+
|
|
17
|
+
_REGISTRIES: "weakref.WeakKeyDictionary" = weakref.WeakKeyDictionary()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Registry:
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self.marks: list[Mark] = []
|
|
23
|
+
self._overlay_counts: dict[str, int] = {}
|
|
24
|
+
self._series_slugs: dict[str, str] = {}
|
|
25
|
+
# colour-control keys claimed so far (fields.control_key): two same-named colour-mapped
|
|
26
|
+
# series never share a recipe entry
|
|
27
|
+
self._color_keys: set[str] = set()
|
|
28
|
+
# shared colour scales declared with fp.color_scale (fields.SharedScale), by name
|
|
29
|
+
self._scales: dict = {}
|
|
30
|
+
# problems the auto-tagger met that a save should report (SaveResult.warnings), instead
|
|
31
|
+
# of swallowing them
|
|
32
|
+
self.warnings: list[str] = []
|
|
33
|
+
|
|
34
|
+
def add(self, mark: Mark) -> Mark:
|
|
35
|
+
if mark.axes is None and mark.artists:
|
|
36
|
+
mark.axes = getattr(mark.artists[0], "axes", None)
|
|
37
|
+
# Two DIFFERENT series names normalizing to one slug would silently produce
|
|
38
|
+
# order-dependent "-2" ids — a safety net, not durable identity (plan §7). Fail at
|
|
39
|
+
# registration, where the traceback points at the user's own call site.
|
|
40
|
+
if mark.series is not None:
|
|
41
|
+
slug = _ids.series_root(mark.series)
|
|
42
|
+
first = self._series_slugs.setdefault((mark.axes, slug), str(mark.series))
|
|
43
|
+
if first != str(mark.series):
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"series name {str(mark.series)!r} collides with {first!r}: both normalize "
|
|
46
|
+
f"to the id {slug!r} (ids are slugified: lowercase, spaces/underscores "
|
|
47
|
+
"become '-'). Give each series a stable, distinct name."
|
|
48
|
+
)
|
|
49
|
+
self.marks.append(mark)
|
|
50
|
+
return mark
|
|
51
|
+
|
|
52
|
+
def next_overlay_index(self, role: str) -> int:
|
|
53
|
+
n = self._overlay_counts.get(role, 0)
|
|
54
|
+
self._overlay_counts[role] = n + 1
|
|
55
|
+
return n
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def registry_for(fig) -> Registry:
|
|
59
|
+
reg = getattr(fig, "_fluxplot_registry", None)
|
|
60
|
+
if reg is None:
|
|
61
|
+
reg = Registry()
|
|
62
|
+
fig._fluxplot_registry = reg
|
|
63
|
+
_REGISTRIES[fig] = weakref.ref(reg)
|
|
64
|
+
return reg
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def clear(fig) -> None:
|
|
68
|
+
_REGISTRIES.pop(fig, None)
|
|
69
|
+
if hasattr(fig, "_fluxplot_registry"):
|
|
70
|
+
del fig._fluxplot_registry
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def snapshot(fig):
|
|
74
|
+
from .data import refresh
|
|
75
|
+
reg = Registry()
|
|
76
|
+
for original in registry_for(fig).marks:
|
|
77
|
+
mark = replace(original, artists=list(original.artists), data=dict(original.data),
|
|
78
|
+
member_gids=[], member_indices=[])
|
|
79
|
+
refresh(mark)
|
|
80
|
+
reg.add(mark)
|
|
81
|
+
return reg
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@contextmanager
|
|
85
|
+
def temporary_gids(fig, reg):
|
|
86
|
+
artists = {id(a): a for a in fig.findobj()}
|
|
87
|
+
for m in reg.marks:
|
|
88
|
+
for a in m.artists:
|
|
89
|
+
artists[id(a)] = a
|
|
90
|
+
before = [(a, a.get_gid()) for a in artists.values()]
|
|
91
|
+
try:
|
|
92
|
+
yield
|
|
93
|
+
finally:
|
|
94
|
+
for a, gid in before:
|
|
95
|
+
a.set_gid(gid)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def fig_of(artist):
|
|
99
|
+
"""Resolve the Figure that owns an artist (or container)."""
|
|
100
|
+
fig = getattr(artist, "figure", None)
|
|
101
|
+
if fig is not None:
|
|
102
|
+
return fig
|
|
103
|
+
ax = getattr(artist, "axes", None)
|
|
104
|
+
if ax is not None:
|
|
105
|
+
return ax.figure
|
|
106
|
+
raise ValueError(f"cannot resolve a Figure from {artist!r}; tag the artist after it is added")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# gid assignment
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
def resolve_gids(reg: Registry, alloc: "_ids.IdAllocator") -> None:
|
|
113
|
+
"""Assign deterministic gids to every Mark's artist(s), in insertion order."""
|
|
114
|
+
for m in reg.marks: # a shared collection's group is claimed afresh on every resolve
|
|
115
|
+
for a in m.artists:
|
|
116
|
+
if hasattr(a, "_fluxplot_shared_points"):
|
|
117
|
+
a._fluxplot_shared_points = None
|
|
118
|
+
for m in reg.marks:
|
|
119
|
+
# reset resolved fields so re-saving the same figure is idempotent
|
|
120
|
+
m.gid = None
|
|
121
|
+
m.member_gids = []
|
|
122
|
+
m.member_indices = []
|
|
123
|
+
m.split_use = False
|
|
124
|
+
if m.series is not None:
|
|
125
|
+
_resolve_series_mark(m, alloc)
|
|
126
|
+
else:
|
|
127
|
+
_resolve_overlay_mark(m, alloc)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _resolve_series_mark(m: Mark, alloc: "_ids.IdAllocator") -> None:
|
|
131
|
+
series = m.series
|
|
132
|
+
if m.data.get('contour_legacy'):
|
|
133
|
+
m.gid = alloc.take(_ids.series_id(series, m.role))
|
|
134
|
+
for i, art in enumerate(m.artists):
|
|
135
|
+
gid = m.gid + f'.level.{i}'
|
|
136
|
+
art.set_gid(gid)
|
|
137
|
+
m.member_gids.append(gid)
|
|
138
|
+
elif m.role == "point":
|
|
139
|
+
# a named point mark (one per category under a hue series) nests under the name:
|
|
140
|
+
# <series>.<name>.points / .point.k
|
|
141
|
+
base = _ids.series_id(series, _ids.slugify(m.name)) if m.name is not None else _ids.series_root(series)
|
|
142
|
+
m.gid = alloc.take(base + ".points")
|
|
143
|
+
from .data import point_indices
|
|
144
|
+
art = m.artists[0]
|
|
145
|
+
subset = m.data.get("point_subset")
|
|
146
|
+
if subset is not None and art.get_gid() and getattr(art, "_fluxplot_shared_points", None) is not None:
|
|
147
|
+
# a second series drawn by the SAME collection (seaborn's scatterplot(hue=)): the
|
|
148
|
+
# group belongs to the first; this mark owns only its members
|
|
149
|
+
m.data["point_group"] = art._fluxplot_shared_points
|
|
150
|
+
else:
|
|
151
|
+
art.set_gid(m.gid)
|
|
152
|
+
if subset is not None:
|
|
153
|
+
art._fluxplot_shared_points = m.gid
|
|
154
|
+
m.data.pop("point_group", None)
|
|
155
|
+
m.split_use = True
|
|
156
|
+
m.member_indices = point_indices(m)
|
|
157
|
+
if subset is not None:
|
|
158
|
+
# the position of each member among the collection's drawn (finite) points
|
|
159
|
+
finite = [i for i in range(min(len(m.x or []), len(m.y or []))) if m.x[i] is not None and m.y[i] is not None]
|
|
160
|
+
pos = {i: k for k, i in enumerate(finite)}
|
|
161
|
+
m.data["point_positions"] = [pos[i] for i in m.member_indices]
|
|
162
|
+
m.data["point_drawn"] = len(finite)
|
|
163
|
+
m.member_gids = [alloc.take(base + f".point.{k}") for k in m.member_indices]
|
|
164
|
+
elif m.role == "bar":
|
|
165
|
+
for k, art in enumerate(m.artists):
|
|
166
|
+
cid = alloc.take(_ids.series_id(series, "bar", k))
|
|
167
|
+
art.set_gid(cid)
|
|
168
|
+
m.member_gids.append(cid)
|
|
169
|
+
else: # line, area, errorbar, box, or a generic series role
|
|
170
|
+
# A series mark may carry an explicit ``name`` to distinguish it from its siblings — several
|
|
171
|
+
# marks of the SAME role under one series (e.g. one collection per region of a surface map).
|
|
172
|
+
# Then the name, not the role, is the meaningful id segment: ``atlas.frontal`` rather
|
|
173
|
+
# than ``atlas.surface-region-2``, so the part is addressable by what it actually is.
|
|
174
|
+
# Roles with a single mark per series (line/area/errorbar/box) never set ``name``, so their
|
|
175
|
+
# ids are unchanged.
|
|
176
|
+
seg = _ids.slugify(m.name) if m.name is not None else m.role
|
|
177
|
+
for k, art in enumerate(m.artists):
|
|
178
|
+
cid = (
|
|
179
|
+
alloc.take(_ids.series_id(series, seg))
|
|
180
|
+
if k == 0
|
|
181
|
+
else alloc.take(_ids.series_id(series, seg, k))
|
|
182
|
+
)
|
|
183
|
+
art.set_gid(cid)
|
|
184
|
+
m.member_gids.append(cid)
|
|
185
|
+
m.gid = m.member_gids[0] if m.member_gids else None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _resolve_overlay_mark(m: Mark, alloc: "_ids.IdAllocator") -> None:
|
|
189
|
+
name = m.name if m.name is not None else str(m.data.get("index", 0))
|
|
190
|
+
m.gid = alloc.take(_ids.join(m.role, _ids.slugify(name)))
|
|
191
|
+
if m.artists:
|
|
192
|
+
m.artists[0].set_gid(m.gid)
|
|
193
|
+
label_artist = m.data.get("label_artist")
|
|
194
|
+
if label_artist is not None:
|
|
195
|
+
label_gid = alloc.take(f"{m.gid}.label")
|
|
196
|
+
label_artist.set_gid(label_gid)
|
|
197
|
+
m.data["label_gid"] = label_gid
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
# scaffold auto-tagging
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
def axis_tick_artists(mpl_axis, primary_side=1):
|
|
204
|
+
"""Yield visible major/minor tick components with stable, side-aware suffixes.
|
|
205
|
+
|
|
206
|
+
Major primary-side IDs retain the original naming. Extra levels/sides gain
|
|
207
|
+
prefixes so adding top/right marks never renumbers the existing bottom/left
|
|
208
|
+
marks. The renderer still determines which boundary components survive.
|
|
209
|
+
"""
|
|
210
|
+
for level, ticks in (("major", mpl_axis.get_major_ticks()),
|
|
211
|
+
("minor", mpl_axis.get_minor_ticks())):
|
|
212
|
+
prefix = "" if level == "major" else "minor."
|
|
213
|
+
for k, tick in enumerate(ticks):
|
|
214
|
+
if not tick.get_visible():
|
|
215
|
+
continue
|
|
216
|
+
for side in (primary_side, 3 - primary_side):
|
|
217
|
+
side_prefix = "" if side == primary_side else "secondary."
|
|
218
|
+
for part, role, attr in (("tick", "tick", f"tick{side}line"),
|
|
219
|
+
("ticklabel", "tick-label", f"label{side}")):
|
|
220
|
+
art = getattr(tick, attr, None)
|
|
221
|
+
if art is None or not art.get_visible():
|
|
222
|
+
continue
|
|
223
|
+
if role == "tick-label" and not art.get_text():
|
|
224
|
+
continue
|
|
225
|
+
yield f"{prefix}{side_prefix}{part}.{k}", role, k, art
|
|
226
|
+
grid = tick.gridline
|
|
227
|
+
if grid.get_visible():
|
|
228
|
+
yield f"{prefix}gridline.{k}", "gridline", k, grid
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def autotag_scaffold(ax, alloc: "_ids.IdAllocator", secondary: str | None = None) -> list[GuideTag]:
|
|
232
|
+
"""Name the axes/title/legend/tick-labels so the user never hand-tags scaffold.
|
|
233
|
+
|
|
234
|
+
Returns GuideTags for the manifest + for ``data-role`` injection. Setting a gid on a scaffold
|
|
235
|
+
artist is harmless even if that artist does not emit a wrapping ``<g>`` — post-processing only
|
|
236
|
+
annotates ids that actually appear, and the manifest records the guide regardless (P4).
|
|
237
|
+
|
|
238
|
+
``secondary="y2"`` / ``"x2"`` tags a twin axes (``ax.twinx()`` / ``twiny()``) as part of its
|
|
239
|
+
primary's panel: only its own value axis (``axis.y2.*``) and that axis' spine are named — the
|
|
240
|
+
shared axis, the background and the titles belong to the primary.
|
|
241
|
+
"""
|
|
242
|
+
guides: list[GuideTag] = []
|
|
243
|
+
|
|
244
|
+
is3d = getattr(ax, "name", None) == "3d"
|
|
245
|
+
if secondary is not None:
|
|
246
|
+
axes = [(secondary, ax.yaxis if secondary == "y2" else ax.xaxis)]
|
|
247
|
+
else:
|
|
248
|
+
axes = [("x", ax.xaxis), ("y", ax.yaxis)]
|
|
249
|
+
if is3d:
|
|
250
|
+
axes.append(("z", ax.zaxis))
|
|
251
|
+
for which, mpl_axis in axes:
|
|
252
|
+
# the WHOLE axis as a real <g id="axis.x"> wrapper, so the manifest's axis ref
|
|
253
|
+
# resolves and "hide the entire X axis" targets one element.
|
|
254
|
+
axis_gid = alloc.take(_ids.axis_id(which))
|
|
255
|
+
# Axes3D draws one Axis through three separate groups with the same
|
|
256
|
+
# gid. Name its actual components and use a virtual organizational axis.
|
|
257
|
+
mpl_axis.set_gid(None if is3d else axis_gid)
|
|
258
|
+
guides.append(GuideTag(gid=axis_gid, role="axis", axis=which, virtual=is3d))
|
|
259
|
+
if is3d:
|
|
260
|
+
for role, art in (("spine", mpl_axis.line), ("background", mpl_axis.pane),
|
|
261
|
+
("gridline", mpl_axis.gridlines)):
|
|
262
|
+
gid = alloc.take(_ids.axis_id(which, role))
|
|
263
|
+
art.set_gid(gid)
|
|
264
|
+
guides.append(GuideTag(gid=gid, role=role, axis=which))
|
|
265
|
+
|
|
266
|
+
title_gid = alloc.take(_ids.axis_id(which, "title"))
|
|
267
|
+
mpl_axis.label.set_gid(title_gid)
|
|
268
|
+
guides.append(
|
|
269
|
+
GuideTag(gid=title_gid, role="axis-title", axis=which, text=mpl_axis.label.get_text())
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# a twin's value axis draws its ticks on the far side (right / top): that side is its primary
|
|
273
|
+
far = secondary is not None and mpl_axis.get_ticks_position() in ("top", "right", "default", "unknown")
|
|
274
|
+
if secondary is not None and mpl_axis.get_ticks_position() in ("bottom", "left"):
|
|
275
|
+
far = False # a secondary_xaxis("bottom") keeps the near side
|
|
276
|
+
for suffix, role, k, art in axis_tick_artists(mpl_axis, primary_side=2 if far else 1):
|
|
277
|
+
g = alloc.take(f"axis.{which}.{suffix}")
|
|
278
|
+
art.set_gid(g)
|
|
279
|
+
guides.append(GuideTag(gid=g, role=role, axis=which, index=k,
|
|
280
|
+
text=art.get_text() if role == "tick-label" else None))
|
|
281
|
+
|
|
282
|
+
# spines. Rectangular axes key them bottom/left/top/right; polar axes key them
|
|
283
|
+
# polar/start/end/inner (so the old side list silently dropped every polar spine).
|
|
284
|
+
# Axis assignment follows the along-direction convention (a bottom spine runs along
|
|
285
|
+
# x → axis "x"): the outer "polar" circle and the "inner" circle run along theta → x;
|
|
286
|
+
# the "start"/"end" wedge edges run along r → y. Invisible/absent sides are skipped;
|
|
287
|
+
# the spine's own key travels as `text`, exactly like the rectangular sides do.
|
|
288
|
+
if secondary is not None: # a twin's or secondary axis' own spine(s): whichever are visible
|
|
289
|
+
wanted = ("left", "right") if secondary == "y2" else ("bottom", "top")
|
|
290
|
+
sides = tuple((side, secondary) for side in wanted if side in ax.spines and ax.spines[side].get_visible())
|
|
291
|
+
if getattr(ax, "_functions", None) is None: # a plain twin shares the primary's near spine
|
|
292
|
+
sides = tuple((side, w) for side, w in sides if side in ("right", "top"))
|
|
293
|
+
elif getattr(ax, "name", None) == "polar":
|
|
294
|
+
sides = (("polar", "x"), ("inner", "x"), ("start", "y"), ("end", "y"))
|
|
295
|
+
else:
|
|
296
|
+
sides = (("bottom", "x"), ("left", "y"), ("top", "x"), ("right", "y"))
|
|
297
|
+
seen_spines: dict[str, int] = {}
|
|
298
|
+
for side, which in sides:
|
|
299
|
+
try:
|
|
300
|
+
sp = ax.spines[side]
|
|
301
|
+
except (KeyError, TypeError):
|
|
302
|
+
continue
|
|
303
|
+
if not sp.get_visible():
|
|
304
|
+
continue
|
|
305
|
+
# the side is part of the id (axis.x.spine.bottom): a second visible spine no longer
|
|
306
|
+
# depends on collision repair for its name. The id an older fluxplot gave this spine
|
|
307
|
+
# (axis.x.spine, axis.x.spine-2, …) travels as an alias for one minor version.
|
|
308
|
+
k = seen_spines.get(which, 0)
|
|
309
|
+
seen_spines[which] = k + 1
|
|
310
|
+
g = alloc.take(_ids.axis_id(which, "spine") + "." + side)
|
|
311
|
+
sp.set_gid(g)
|
|
312
|
+
legacy = _ids.axis_id(which, "spine") + ("" if k == 0 else f"-{k + 1}")
|
|
313
|
+
guides.append(GuideTag(gid=g, role="spine", axis=which, text=side, data={"alias": legacy}))
|
|
314
|
+
|
|
315
|
+
# the grounds: the axes' and the figure's background patches (and a framed legend's box) are
|
|
316
|
+
# parts too — the paints a theme swaps first. The figure patch is shared by every panel; the
|
|
317
|
+
# get_gid() guard tags it once.
|
|
318
|
+
if secondary is None and ax.patch is not None and ax.patch.get_visible():
|
|
319
|
+
g = alloc.take("axes.background")
|
|
320
|
+
ax.patch.set_gid(g)
|
|
321
|
+
guides.append(GuideTag(gid=g, role="background", text="axes"))
|
|
322
|
+
# (the figure's own background is figure scope: autotag_figure)
|
|
323
|
+
|
|
324
|
+
legend = ax.get_legend()
|
|
325
|
+
if legend is not None:
|
|
326
|
+
g = alloc.take("legend")
|
|
327
|
+
legend.set_gid(g)
|
|
328
|
+
guides.append(GuideTag(gid=g, role="legend"))
|
|
329
|
+
frame = legend.legendPatch
|
|
330
|
+
if legend.get_frame_on() and frame is not None and frame.get_visible():
|
|
331
|
+
fg = alloc.take("legend.background")
|
|
332
|
+
frame.set_gid(fg)
|
|
333
|
+
guides.append(GuideTag(gid=fg, role="background", text="legend"))
|
|
334
|
+
# per-entry swatch + label, each knowing the artist it stands for (legend_sources): the
|
|
335
|
+
# manifest joins entry ↔ series on that artist, not on the label text
|
|
336
|
+
sources = legend_sources(legend, [ax])
|
|
337
|
+
for k, txt in enumerate(legend.get_texts()):
|
|
338
|
+
lg = alloc.take(_ids.join("legend", "entry", k, "label"))
|
|
339
|
+
txt.set_gid(lg)
|
|
340
|
+
guides.append(GuideTag(gid=lg, role="legend-label", index=k, text=txt.get_text(),
|
|
341
|
+
data={"_source": sources.get(k)}))
|
|
342
|
+
for k, h in enumerate(getattr(legend, "legend_handles", None) or []):
|
|
343
|
+
try:
|
|
344
|
+
sg = alloc.take(_ids.join("legend", "entry", k, "swatch"))
|
|
345
|
+
h.set_gid(sg)
|
|
346
|
+
guides.append(GuideTag(gid=sg, role="legend-swatch", index=k, data={"_source": sources.get(k)}))
|
|
347
|
+
except Exception as exc: # a handle that is no Artist: said, not swallowed
|
|
348
|
+
registry_for(ax.figure).warnings.append(
|
|
349
|
+
f"legend entry {k}: swatch {type(h).__name__} could not be tagged ({exc})")
|
|
350
|
+
|
|
351
|
+
# Titles: the house style writes a LEFT title (matplotlib's ax._left_title), so
|
|
352
|
+
# inspecting only ax.title (center) misses it. Tag every title slot that carries
|
|
353
|
+
# text — left / center / right. (The figure's suptitle is figure scope: autotag_figure.)
|
|
354
|
+
titles = () if secondary else (getattr(ax, "_left_title", None), ax.title, getattr(ax, "_right_title", None))
|
|
355
|
+
for t in titles:
|
|
356
|
+
if t is not None and t.get_text().strip() and not t.get_gid():
|
|
357
|
+
g = alloc.take("figure.title")
|
|
358
|
+
t.set_gid(g)
|
|
359
|
+
guides.append(GuideTag(gid=g, role="title", text=t.get_text()))
|
|
360
|
+
|
|
361
|
+
# Free-text sweep: any remaining un-tagged text artist (equation boxes, data /
|
|
362
|
+
# value labels, callouts dropped with raw ax.text / ax.annotate) becomes an
|
|
363
|
+
# addressable annotation, so nothing escapes the scene graph as an anonymous
|
|
364
|
+
# text_N. Artists already tagged (titles above, fp.annotation/fp.tag overlays
|
|
365
|
+
# resolved earlier) carry a gid and are skipped. Figure-level text is autotag_figure's.
|
|
366
|
+
k = 0
|
|
367
|
+
for t in list(ax.texts):
|
|
368
|
+
if not t.get_text().strip() or t.get_gid():
|
|
369
|
+
continue
|
|
370
|
+
g = alloc.take(_ids.join("annotation", k))
|
|
371
|
+
t.set_gid(g)
|
|
372
|
+
guides.append(GuideTag(gid=g, role="annotation", text=t.get_text()))
|
|
373
|
+
k += 1
|
|
374
|
+
|
|
375
|
+
# Orphan-artist sweep: the mirror of the free-text sweep for non-text primitives. Raw
|
|
376
|
+
# ax.plot() lines, ax.add_collection()/scatter collections and ax.add_patch() patches that
|
|
377
|
+
# the user never routed through an fp.* helper end up as bare <g id="line2d_N"> — no
|
|
378
|
+
# data-role, absent from the manifest, so Flux can't mask/animate them. Series & overlay
|
|
379
|
+
# artists were gid'd earlier by resolve_gids (which runs before this), so a leftover gid is
|
|
380
|
+
# the exact "already tagged, skip me" signal the text sweep relies on. We assign extra.line.N
|
|
381
|
+
# / extra.collection.N / extra.patch.N and role "extra". The axes' own background patch is
|
|
382
|
+
# mpl scaffolding, not user content — exclude it (spines/ticks/gridlines live in the axis
|
|
383
|
+
# containers, not these lists, so they never appear here).
|
|
384
|
+
_sweep_extra(ax, alloc, guides)
|
|
385
|
+
|
|
386
|
+
return guides
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def autotag_figure(fig, alloc: "_ids.IdAllocator", first_prefix: str = "") -> list[GuideTag]:
|
|
390
|
+
"""Name the figure-scope artists once, unprefixed: ``fig.suptitle`` → ``figure.title``,
|
|
391
|
+
``supxlabel`` / ``supylabel`` → ``figure.xlabel`` / ``figure.ylabel``, ``fig.legend()`` →
|
|
392
|
+
``figure.legend`` (``figure.legend.k`` for more) with ``.entry.k.label`` / ``.swatch``,
|
|
393
|
+
``fig.text`` → ``figure.annotation.k``, and the figure's own lines / patches / images →
|
|
394
|
+
``figure.extra.<kind>.k``; the figure's own background patch → ``figure.background``. Every
|
|
395
|
+
tag carries ``data["scope"] == "figure"``; they never belong to a panel. Runs before the panel
|
|
396
|
+
scaffolds, so the suptitle claims ``figure.title``. ``first_prefix`` (the first panel's, in a
|
|
397
|
+
multi-panel figure) names the background's pre-0.3.2 id as an alias.
|
|
398
|
+
"""
|
|
399
|
+
guides: list[GuideTag] = []
|
|
400
|
+
scope = {"scope": "figure"}
|
|
401
|
+
fig_patch = getattr(fig, "patch", None)
|
|
402
|
+
if fig_patch is not None and fig_patch.get_visible() and not fig_patch.get_gid():
|
|
403
|
+
g = alloc.take("figure.background")
|
|
404
|
+
fig_patch.set_gid(g)
|
|
405
|
+
data = {**scope}
|
|
406
|
+
if first_prefix: # before 0.3.2 the first panel's prefix was on it
|
|
407
|
+
data["alias"] = first_prefix + "figure.background"
|
|
408
|
+
guides.append(GuideTag(gid=g, role="background", text="figure", data=data))
|
|
409
|
+
sup = getattr(fig, "_suptitle", None)
|
|
410
|
+
if sup is not None and sup.get_text().strip() and not sup.get_gid():
|
|
411
|
+
g = alloc.take("figure.title")
|
|
412
|
+
sup.set_gid(g)
|
|
413
|
+
guides.append(GuideTag(gid=g, role="title", text=sup.get_text(), data={**scope, "slot": "title"}))
|
|
414
|
+
for attr, slot in (("_supxlabel", "xlabel"), ("_supylabel", "ylabel")):
|
|
415
|
+
t = getattr(fig, attr, None)
|
|
416
|
+
if t is not None and t.get_text().strip() and not t.get_gid():
|
|
417
|
+
g = alloc.take("figure." + slot)
|
|
418
|
+
t.set_gid(g)
|
|
419
|
+
guides.append(GuideTag(gid=g, role="title", text=t.get_text(), data={**scope, "slot": slot}))
|
|
420
|
+
for k, legend in enumerate(getattr(fig, "legends", []) or []):
|
|
421
|
+
if not legend.get_visible() or legend.get_gid():
|
|
422
|
+
continue
|
|
423
|
+
g = alloc.take("figure.legend" if k == 0 else f"figure.legend.{k}")
|
|
424
|
+
legend.set_gid(g)
|
|
425
|
+
guides.append(GuideTag(gid=g, role="legend", index=k, data={**scope}))
|
|
426
|
+
frame = legend.legendPatch
|
|
427
|
+
if legend.get_frame_on() and frame is not None and frame.get_visible():
|
|
428
|
+
fg = alloc.take(g + ".background")
|
|
429
|
+
frame.set_gid(fg)
|
|
430
|
+
guides.append(GuideTag(gid=fg, role="background", text=g, data={**scope, "legend": g}))
|
|
431
|
+
sources = legend_sources(legend, fig.axes)
|
|
432
|
+
for e, txt in enumerate(legend.get_texts()):
|
|
433
|
+
lg = alloc.take(f"{g}.entry.{e}.label")
|
|
434
|
+
txt.set_gid(lg)
|
|
435
|
+
guides.append(GuideTag(gid=lg, role="legend-label", index=e, text=txt.get_text(),
|
|
436
|
+
data={**scope, "legend": g, "_source": sources.get(e)}))
|
|
437
|
+
for e, h in enumerate(getattr(legend, "legend_handles", None) or []):
|
|
438
|
+
try:
|
|
439
|
+
sg = alloc.take(f"{g}.entry.{e}.swatch")
|
|
440
|
+
h.set_gid(sg)
|
|
441
|
+
guides.append(GuideTag(gid=sg, role="legend-swatch", index=e,
|
|
442
|
+
data={**scope, "legend": g, "_source": sources.get(e)}))
|
|
443
|
+
except Exception as exc:
|
|
444
|
+
registry_for(fig).warnings.append(
|
|
445
|
+
f"figure legend entry {e}: swatch {type(h).__name__} could not be tagged ({exc})")
|
|
446
|
+
k = 0
|
|
447
|
+
for t in list(fig.texts):
|
|
448
|
+
if not t.get_text().strip() or t.get_gid():
|
|
449
|
+
continue
|
|
450
|
+
g = alloc.take(f"figure.annotation.{k}")
|
|
451
|
+
t.set_gid(g)
|
|
452
|
+
guides.append(GuideTag(gid=g, role="annotation", text=t.get_text(), data={**scope}))
|
|
453
|
+
k += 1
|
|
454
|
+
for kind, artists in (("line", list(fig.lines)), ("patch", list(fig.patches)), ("image", list(fig.images))):
|
|
455
|
+
n = 0
|
|
456
|
+
for art in artists:
|
|
457
|
+
if art is fig.patch or not art.get_visible() or art.get_gid():
|
|
458
|
+
continue
|
|
459
|
+
g = alloc.take(f"figure.extra.{kind}.{n}")
|
|
460
|
+
art.set_gid(g)
|
|
461
|
+
guides.append(GuideTag(gid=g, role="extra", index=n, kind=artist_kind(art), data={**scope}))
|
|
462
|
+
n += 1
|
|
463
|
+
return guides
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def legend_sources(legend, axes) -> dict:
|
|
467
|
+
"""``{entry index: source artist}`` for a legend: the artists its entries stand for.
|
|
468
|
+
|
|
469
|
+
``fp.legend(ax, handles, labels)`` records the handles it was given. A legend made with
|
|
470
|
+
``ax.legend()`` lists the axes' labelled artists in matplotlib's own order, so when the
|
|
471
|
+
legend's texts still equal those labels one for one, that order is the mapping. Anything else
|
|
472
|
+
(hand-picked handles through raw ``ax.legend(...)``, texts edited afterwards) yields nothing
|
|
473
|
+
rather than a guess.
|
|
474
|
+
"""
|
|
475
|
+
texts = [t.get_text() for t in legend.get_texts()]
|
|
476
|
+
explicit = getattr(legend, "_fluxplot_handles", None)
|
|
477
|
+
if explicit is not None:
|
|
478
|
+
return {k: h for k, h in enumerate(explicit) if k < len(texts)}
|
|
479
|
+
handles, labels = [], []
|
|
480
|
+
for ax in axes:
|
|
481
|
+
h, lbl = ax.get_legend_handles_labels()
|
|
482
|
+
handles += h
|
|
483
|
+
labels += lbl
|
|
484
|
+
if handles and labels == texts:
|
|
485
|
+
return dict(enumerate(handles))
|
|
486
|
+
return {}
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _sweep_extra(ax, alloc: "_ids.IdAllocator", guides: list) -> None:
|
|
490
|
+
background = getattr(ax, "patch", None)
|
|
491
|
+
for kind, artists in (
|
|
492
|
+
("line", list(ax.lines)),
|
|
493
|
+
("collection", list(ax.collections)),
|
|
494
|
+
("patch", list(ax.patches)),
|
|
495
|
+
# Images (raw ax.imshow) complete the sweep. Beyond consistency this is load-bearing
|
|
496
|
+
# for auto-rasterization: it guarantees every image-emitting artist carries a gid, so
|
|
497
|
+
# an <image> left with matplotlib's generated id is, by construction, one that
|
|
498
|
+
# rasterization produced — which is how raster.reattach identifies them. (The figure's
|
|
499
|
+
# own lines / patches / images are autotag_figure's.)
|
|
500
|
+
("image", list(ax.images)),
|
|
501
|
+
# ax.artists holds what add_artist() placed: anchored boxes (an AnchoredSizeBar, an
|
|
502
|
+
# AnchoredText), offset images, arbitrary artists; ax.tables holds table() output.
|
|
503
|
+
("artist", list(ax.artists)),
|
|
504
|
+
("table", list(ax.tables)),
|
|
505
|
+
):
|
|
506
|
+
n = 0
|
|
507
|
+
for art in artists:
|
|
508
|
+
if art is background:
|
|
509
|
+
continue
|
|
510
|
+
if not art.get_visible() or art.get_gid():
|
|
511
|
+
continue
|
|
512
|
+
g = alloc.take(_ids.join("extra", kind, n))
|
|
513
|
+
if kind == "artist" and _is_offsetbox(art):
|
|
514
|
+
# an OffsetBox draws its children without a wrapping group of its own: name the
|
|
515
|
+
# children so they, and not an invisible container, are addressable — the first
|
|
516
|
+
# drawable as the box's id, further drawables as .k, its text as .label
|
|
517
|
+
_tag_offsetbox(art, g, alloc, guides)
|
|
518
|
+
n += 1
|
|
519
|
+
continue
|
|
520
|
+
art.set_gid(g)
|
|
521
|
+
# "extra" has no static kind (role is heterogeneous) — infer from the artist
|
|
522
|
+
guides.append(GuideTag(gid=g, role="extra", index=n,
|
|
523
|
+
kind="container" if kind == "table" else artist_kind(art)))
|
|
524
|
+
n += 1
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _is_offsetbox(art) -> bool:
|
|
528
|
+
from matplotlib.offsetbox import OffsetBox
|
|
529
|
+
return isinstance(art, OffsetBox)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _tag_offsetbox(box, gid: str, alloc: "_ids.IdAllocator", guides: list) -> None:
|
|
533
|
+
from matplotlib.offsetbox import OffsetBox
|
|
534
|
+
from matplotlib.text import Text
|
|
535
|
+
leaves, texts = [], []
|
|
536
|
+
|
|
537
|
+
def visit(a):
|
|
538
|
+
if not a.get_visible():
|
|
539
|
+
return
|
|
540
|
+
if isinstance(a, OffsetBox):
|
|
541
|
+
for child in a.get_children():
|
|
542
|
+
visit(child)
|
|
543
|
+
patch = getattr(a, "patch", None)
|
|
544
|
+
if patch is not None and patch.get_visible() and a is box:
|
|
545
|
+
leaves.append(patch)
|
|
546
|
+
elif isinstance(a, Text):
|
|
547
|
+
if a.get_text().strip():
|
|
548
|
+
texts.append(a)
|
|
549
|
+
elif hasattr(a, "set_gid"):
|
|
550
|
+
leaves.append(a)
|
|
551
|
+
|
|
552
|
+
visit(box)
|
|
553
|
+
box.set_gid(gid) # harmless: the box emits no group, and marks it swept
|
|
554
|
+
for k, leaf in enumerate(leaves):
|
|
555
|
+
g = gid if k == 0 else alloc.take(f"{gid}.{k}")
|
|
556
|
+
leaf.set_gid(g)
|
|
557
|
+
guides.append(GuideTag(gid=g, role="extra", index=k if k else None, kind=artist_kind(leaf)))
|
|
558
|
+
for k, txt in enumerate(texts):
|
|
559
|
+
g = alloc.take(f"{gid}.label" if k == 0 else f"{gid}.label.{k}")
|
|
560
|
+
txt.set_gid(g)
|
|
561
|
+
guides.append(GuideTag(gid=g, role="label", index=k if k else None, text=txt.get_text(), kind="text"))
|
fluxplot/version.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Single source of truth for version + spec version (kept separate to avoid import cycles)."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
# The manifest/recipe schema version this build emits.
|
|
6
|
+
# 0.2.0 — completed the parts model: gridlines/ticks/spines/legend-entries tagged,
|
|
7
|
+
# real axis.x/axis.y group wrappers, and group nodes (members[]) in the parts tree.
|
|
8
|
+
# 0.3.0 — lossless observations/null gaps, panel ownership, explicit transform
|
|
9
|
+
# capabilities, component inventories and scalar-field/colorbar metadata.
|
|
10
|
+
# 0.3.1 — additive: every parts-tree leaf carries its role, groups their memberRole, series and
|
|
11
|
+
# legend entries a label; build presets use a closed animation vocabulary with stagger
|
|
12
|
+
# hints; colour-control keys name the series (legacy positional keys still honoured).
|
|
13
|
+
# 0.3.2 — additive, with two id renames carried by manifest.idAliases for this minor version:
|
|
14
|
+
# spines are axis.<x|y>.spine.<side>, and series slugs transliterate / hash-suffix names
|
|
15
|
+
# that used to collide. New: colorScales (LUT law, alpha channel), shared scales, style
|
|
16
|
+
# tokens and themes, series colours, quality.color lint, twin axes (y2 / x2), figure-scope
|
|
17
|
+
# parts, images, bands, brackets with stats, tick schemes, member keys and valueMorph,
|
|
18
|
+
# categorical / date data labels, insets, secondary axes, regression / kde / step / stem.
|
|
19
|
+
SPEC_VERSION = "0.3.2"
|