fluxplot 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. fluxplot/__init__.py +115 -0
  2. fluxplot/_fieldmap.py +97 -0
  3. fluxplot/_mesh_reduce.py +54 -0
  4. fluxplot/_scene3d_size.py +95 -0
  5. fluxplot/_viewer/THIRD-PARTY.txt +23 -0
  6. fluxplot/_viewer/flux-model3d-viewer.min.js +4221 -0
  7. fluxplot/_viewer/stamp.json +4 -0
  8. fluxplot/api.py +1196 -0
  9. fluxplot/autotag.py +164 -0
  10. fluxplot/base.mplstyle +0 -0
  11. fluxplot/brackets.py +242 -0
  12. fluxplot/canonical_json.py +23 -0
  13. fluxplot/capture.py +150 -0
  14. fluxplot/colorcheck.py +285 -0
  15. fluxplot/colors.py +727 -0
  16. fluxplot/colorscale.py +477 -0
  17. fluxplot/data.py +178 -0
  18. fluxplot/definitions/colormaps.json +1639 -0
  19. fluxplot/definitions/flexoki.tokens.json +2571 -0
  20. fluxplot/definitions/palettes.json +2547 -0
  21. fluxplot/descriptors.py +87 -0
  22. fluxplot/fields.py +611 -0
  23. fluxplot/fits.py +240 -0
  24. fluxplot/glb.py +84 -0
  25. fluxplot/ids.py +173 -0
  26. fluxplot/images.py +362 -0
  27. fluxplot/integrity.py +27 -0
  28. fluxplot/manifest.py +788 -0
  29. fluxplot/mesh3d.py +376 -0
  30. fluxplot/panels.py +284 -0
  31. fluxplot/postprocess.py +638 -0
  32. fluxplot/presets.py +66 -0
  33. fluxplot/provenance.py +177 -0
  34. fluxplot/raster.py +295 -0
  35. fluxplot/recipe.py +178 -0
  36. fluxplot/render.py +66 -0
  37. fluxplot/roles.py +147 -0
  38. fluxplot/scene3d.py +386 -0
  39. fluxplot/scene3d_manifest.py +112 -0
  40. fluxplot/scene3d_viewer.py +633 -0
  41. fluxplot/schemas/.gitkeep +0 -0
  42. fluxplot/schemas/manifest.schema.json +2479 -0
  43. fluxplot/schemas/recipe.schema.json +179 -0
  44. fluxplot/schemas/scene3d.schema.json +461 -0
  45. fluxplot/seaborn_adapters.py +323 -0
  46. fluxplot/signature_fluxplots/__init__.py +18 -0
  47. fluxplot/signature_fluxplots/_colour.py +412 -0
  48. fluxplot/signature_fluxplots/fluxbox.py +433 -0
  49. fluxplot/signature_fluxplots/glowbar.py +769 -0
  50. fluxplot/signature_fluxplots/hexmatrix.py +927 -0
  51. fluxplot/stats/__init__.py +63 -0
  52. fluxplot/stats/_common.py +196 -0
  53. fluxplot/stats/multi_group.py +443 -0
  54. fluxplot/stats/paired.py +209 -0
  55. fluxplot/stats/two_group.py +149 -0
  56. fluxplot/style.py +469 -0
  57. fluxplot/surface.py +487 -0
  58. fluxplot/surface3d.py +197 -0
  59. fluxplot/tagger.py +561 -0
  60. fluxplot/version.py +19 -0
  61. fluxplot-0.1.0.dist-info/METADATA +1199 -0
  62. fluxplot-0.1.0.dist-info/RECORD +65 -0
  63. fluxplot-0.1.0.dist-info/WHEEL +4 -0
  64. fluxplot-0.1.0.dist-info/licenses/LICENSE +21 -0
  65. fluxplot-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +472 -0
fluxplot/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"