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/manifest.py ADDED
@@ -0,0 +1,788 @@
1
+ """Assemble the ``*.fluxplot.json`` manifest — the semantic index (spec §4–§8).
2
+
3
+ Authority rule: geometry stays in the SVG (we never copy paths/bboxes here); the manifest owns
4
+ data, semantics, the coordinate mapping, and choreography. Every node points *into* the SVG by id.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from . import ids as _ids
9
+ from . import presets as _presets
10
+ from . import roles as _roles
11
+ from .capture import svg_viewbox
12
+ from .descriptors import mark_kind
13
+
14
+
15
+ def _floats(seq):
16
+ from .data import values
17
+ return values(seq)
18
+
19
+
20
+ # Composite sub-part roles → the plural series-svg key listing every member. An errorbar/box/
21
+ # violin renders its statistics as numbered sibling groups; collecting EVERY member across the
22
+ # series' marks makes each whisker/cap/median addressable (the shipped errorbar pattern,
23
+ # generalized per plan §4). The singular key keeps the first gid as the primary ref (compat).
24
+ COMPOSITE_ROLES = {
25
+ "errorbar": "errorbars",
26
+ "whisker": "whiskers",
27
+ "cap": "caps",
28
+ "median": "medians",
29
+ "flier": "fliers",
30
+ "mean": "means",
31
+ "segment": "segments",
32
+ # A surface (brain) map draws one collection per category, so every region is separately
33
+ # addressable; ``regions`` lists them all while ``surface-region`` keeps the first as the
34
+ # primary ref (compat, as for the other composites).
35
+ "surface-region": "regions",
36
+ }
37
+
38
+
39
+ def build_manifest(
40
+ fig,
41
+ reg,
42
+ guides,
43
+ axes_capture: list,
44
+ plot_type: str,
45
+ svg_filename: str,
46
+ spec_version: str,
47
+ fluxplot_version: str,
48
+ mpl_version: str,
49
+ present: set | None = None,
50
+ svg_sha256: str | None = None,
51
+ rasterized: set | None = None,
52
+ extra_scales=(),
53
+ style: dict | None = None,
54
+ quality: dict | None = None,
55
+ ) -> dict:
56
+ vbw, vbh = svg_viewbox(fig)
57
+ # gids rendered as a single embedded <image> instead of vector primitives (raster.py).
58
+ # Additive: consumers that ignore it are unaffected; those that read it know why a
59
+ # point cloud has no per-point ids and can label the layer honestly.
60
+ rasterized = rasterized or set()
61
+
62
+ # `present` = the gids that actually survived into the SVG (matplotlib culls
63
+ # boundary ticks and renders point clouds as collections where per-point ids
64
+ # can't be assigned). Reference only what's really there so the parts tree /
65
+ # group members stay honest. None = keep everything (direct/legacy callers).
66
+ def _keep(gid) -> bool:
67
+ return present is None or gid in present
68
+
69
+ # group series marks by series name (insertion order preserved)
70
+ by_series: dict[str, list] = {}
71
+ overlays = []
72
+ for m in reg.marks:
73
+ if m.series is not None:
74
+ by_series.setdefault(m.series, []).append(m)
75
+ else:
76
+ overlays.append(m)
77
+
78
+ series_entries = []
79
+ # per-series {role → data-kind hint} for the parts tree (artist-informed for x- roles)
80
+ series_kinds: dict = {}
81
+ for series, marks in by_series.items():
82
+ svg: dict = {}
83
+ data: dict = {}
84
+ points = None
85
+ kind = None
86
+ label = None
87
+ roles = sorted({m.role for m in marks})
88
+ kinds: dict = {}
89
+ composite_members: dict[str, list] = {}
90
+ components = []
91
+ for m in marks:
92
+ actual = [g for g in m.member_gids or ([m.gid] if m.gid else []) if _keep(g)]
93
+ if m.data.get('field_members') and _keep(m.gid):
94
+ # a field's members are cells / contour bands / hexagons: say which, so a
95
+ # consumer never has to guess a leaf's role from its id
96
+ member_role = m.data.get('field_member_role') or (
97
+ 'cell' if m.data.get('cells') else 'contour-level')
98
+ components.append({'role': m.role, 'svgId': m.gid, 'memberRole': member_role,
99
+ 'members': [g for g in m.data['field_members'] if _keep(g)]})
100
+ elif m.role == "point" and (_keep(m.gid) or (m.data.get("point_group") and _keep(m.data["point_group"]))):
101
+ comp = {"role": "point", "svgId": m.data.get("point_group") or m.gid, "memberRole": "point",
102
+ "members": actual}
103
+ if m.data.get("point_group"): # the SVG group is shared with another series
104
+ comp["groupId"] = m.gid
105
+ components.append(comp)
106
+ else:
107
+ components.extend({"role": m.role, "svgId": g} for g in actual)
108
+ if not actual and not _keep(m.gid) and not (m.data.get("point_group") and _keep(m.data["point_group"])):
109
+ continue
110
+ kind = kind or m.kind
111
+ label = label or m.label
112
+ if m.role not in kinds:
113
+ mk = mark_kind(m)
114
+ if mk is not None:
115
+ kinds[m.role] = mk
116
+ if m.x is not None and not data:
117
+ data = {"x": _floats(m.x), "y": _floats(m.y)}
118
+ if m.role == "line":
119
+ if _keep(m.gid):
120
+ svg.setdefault("line", m.gid)
121
+ elif m.role == "point":
122
+ if _keep(m.gid):
123
+ svg.setdefault("points", m.gid)
124
+ elif m.data.get("point_group") and _keep(m.data["point_group"]):
125
+ svg.setdefault("points", m.data["point_group"])
126
+ points = (points or []) + [
127
+ {
128
+ "index": m.member_indices[k],
129
+ "svgId": m.member_gids[k],
130
+ "x": m.x[m.member_indices[k]],
131
+ "y": m.y[m.member_indices[k]],
132
+ }
133
+ for k in range(len(m.member_gids))
134
+ if _keep(m.member_gids[k])
135
+ ]
136
+ elif m.role == "bar":
137
+ bars = [g for g in m.member_gids if _keep(g)]
138
+ if bars:
139
+ svg.setdefault("bars", []).extend(bars)
140
+ elif m.role in COMPOSITE_ROLES:
141
+ if _keep(m.gid) and m.role not in svg:
142
+ svg[m.role] = m.gid # primary ref stays (compat); first mark wins
143
+ composite_members.setdefault(m.role, []).extend(
144
+ g for g in m.member_gids if _keep(g)
145
+ )
146
+ elif _keep(m.gid):
147
+ svg.setdefault(m.role, m.gid)
148
+ for crole, members in composite_members.items():
149
+ if members:
150
+ svg[COMPOSITE_ROLES[crole]] = members
151
+ # Escape-hatch series (fp.tag) carry no helper kind; fall back to the first
152
+ # tagged role so `kind` is always a string (Flux's validator rejects null).
153
+ if kind is None:
154
+ kind = marks[0].role
155
+ if not svg:
156
+ continue
157
+ datasets = [(m.x, m.y) for m in marks if m.x is not None and _keep(m.gid)]
158
+ if (datasets and any(pair != datasets[0] for pair in datasets[1:])) or sum(m.role == 'point' for m in marks) > 1:
159
+ # Several components may share one semantic series while depicting different
160
+ # observations (a hue level's points across categories). Keep part identity without
161
+ # one false data table; each point still carries its own x / y, and its index counts
162
+ # within its own group (the svgId's prefix says which).
163
+ data = {}
164
+ entry = {
165
+ "id": _ids.series_root(series),
166
+ "name": str(series),
167
+ "kind": kind,
168
+ "roles": roles,
169
+ "svg": svg,
170
+ "data": data,
171
+ "components": components,
172
+ }
173
+ # categorical / date axes: say what each x (y) means, beside the number matplotlib plots
174
+ owner = next((m.axes for m in marks if m.axes is not None), None)
175
+ if data and owner is not None:
176
+ for key, mpl_axis in (("x", owner.xaxis), ("y", owner.yaxis)):
177
+ if data.get(key):
178
+ extra = axis_value_labels(mpl_axis, data[key])
179
+ for k, v in extra.items():
180
+ data[key + k] = v
181
+ for field in ("bar", "band", "uncertainty", "field", "glowbar", "fluxbox", "hexmatrix", "image", "step", "stem",
182
+ "regression", "kde"):
183
+ payload = next((m.data[field] for m in marks if m.data.get(field)), None)
184
+ if payload is not None:
185
+ entry[field] = payload
186
+ axis = next((m.data["axis"] for m in marks if m.data.get("axis")), None)
187
+ if axis is not None: # drawn against a twin's value axis (axes[].y2 / .x2)
188
+ entry["axis"] = axis
189
+ # the series' colour: its primary paint (data.primary_paint) and / or the scale colouring it
190
+ colour = {}
191
+ primary = ("line", "point", "bar", "area", "box", "violin", "errorbar", "x-hexbin", "x-heatmap", "x-contourf", "x-contour")
192
+ drawn = lambda m: _keep(m.gid) or any(_keep(g) for g in m.member_gids) # noqa: E731 (bars have members only)
193
+ paint = next((m.data["color"] for m in marks if m.data.get("color") and drawn(m) and m.role in primary), None)
194
+ if paint is None:
195
+ paint = next((m.data["color"] for m in marks if m.data.get("color") and drawn(m)), None)
196
+ if paint:
197
+ colour.update(paint)
198
+ scale = next((m.data["color_scale"]["id"] for m in marks if m.data.get("color_scale") and _keep(m.gid)), None)
199
+ if scale is not None:
200
+ colour["scale"] = scale
201
+ if colour:
202
+ entry["color"] = colour
203
+ ordinary = all(m.role in ('line', 'point') for m in marks)
204
+ ordinary = ordinary and len([m for m in marks if m.role == 'line']) <= 1
205
+ ordinary = ordinary and len([m for m in marks if m.role == 'point']) <= 1
206
+ for m in marks:
207
+ for a in m.artists:
208
+ if hasattr(a, 'get_drawstyle') and a.get_drawstyle() != 'default': ordinary = False
209
+ if getattr(getattr(a, 'axes', None), 'name', None) not in (None, 'rectilinear'): ordinary = False
210
+ if hasattr(a, 'get_transform') and m.axes is not None and a.get_transform() != m.axes.transData:
211
+ # Collections carry data through their offset transform.
212
+ if not hasattr(a, 'get_offset_transform') or a.get_offset_transform() != m.axes.transData:
213
+ ordinary = False
214
+ entry['capabilities'] = {'dataMorph': bool(ordinary and data and
215
+ not any(c['svgId'] in rasterized for c in components) and
216
+ (svg.get('line') or points))}
217
+ # keyed members (bars by category, cells and hexes by row.col, glowbar / fluxbox parts by
218
+ # category) let two versions of the plot be tweened member by member
219
+ keyed = any((m.role == 'bar' and (m.data.get('bar') or {}).get('keys'))
220
+ or (m.data.get('field_config') and (m.data.get('cells') or m.data.get('hexmatrix')))
221
+ or m.kind in ('glowbar', 'fluxbox') for m in marks)
222
+ entry['capabilities']['valueMorph'] = bool(keyed and not any(c['svgId'] in rasterized for c in components))
223
+ if label:
224
+ entry["label"] = label
225
+ if points:
226
+ entry["points"] = points
227
+ if any(c["svgId"] in rasterized for c in components):
228
+ entry["rasterized"] = True
229
+ # additive provenance for auto-promoted series: how identity/data were captured
230
+ # (identity=artist-label, data=artist — see autotag.py)
231
+ cap = next((m.data["capture"] for m in marks if m.data.get("capture")), None)
232
+ if cap:
233
+ entry["capture"] = cap
234
+ # additive exact-distribution payload (fp.hist bin edges/counts; opt-in raw values) —
235
+ # deliberately separate from data{x,y}: bar heights are not original observations
236
+ dist = next((m.data["distribution"] for m in marks if m.data.get("distribution")), None)
237
+ if dist:
238
+ entry["distribution"] = dist
239
+ # additive surface payload — the complete value→colour contract of a surface (brain) map.
240
+ # The point of the primitive is that this mapping is DATA, not baked pixels: each part is
241
+ # listed with its own id and style, so recolouring a region, swapping a colormap or moving a
242
+ # threshold is a declarative edit that re-renders deterministically.
243
+ surf_marks = [m for m in marks if m.data.get("surface")]
244
+ if surf_marks:
245
+ summary = next((m.data["surface"] for m in surf_marks if m.role == "surface"), {})
246
+ parts_payload = []
247
+ for m in surf_marks:
248
+ if m.role == "surface":
249
+ continue
250
+ p = {k: v for k, v in m.data["surface"].items()
251
+ if k not in ("views", "hemispheres", "missingRule", "missingColor")}
252
+ if _keep(m.gid):
253
+ p["ref"] = m.gid
254
+ p.setdefault("part", m.name or m.role)
255
+ parts_payload.append(p)
256
+ entry["surface"] = {**summary, "parts": parts_payload}
257
+ series_entries.append(entry)
258
+ series_kinds[entry["id"]] = kinds
259
+
260
+ # organize the scaffold guides per axis (+ legend entries + titles + swept text/artists)
261
+ axes_parts, legend_entries, figure_titles, scaffold_annotations, extras, backgrounds = _organize_guides(guides)
262
+
263
+ # guides → manifest guides (axis refs + legend with per-entry svg ids)
264
+ guide_entries = []
265
+ legend_present = any(g.role == "legend" for g in guides)
266
+ for g in guides:
267
+ if g.role == "axis":
268
+ guide_entries.append({"id": g.gid, **({} if g.virtual else {"svgId": g.gid}), "role": "axis", "axis": g.axis})
269
+ if legend_present:
270
+ # entry ↔ series joined by exact, UNIQUE label text — positional order is not
271
+ # identity (plan §7). An entry whose text matches no series label (or an ambiguous
272
+ # duplicated label) keeps its swatch/label as addressable guides, with no series claim.
273
+ by_label: dict[str, list] = {}
274
+ for s in series_entries:
275
+ if s.get("label"):
276
+ by_label.setdefault(s["label"], []).append(s["id"])
277
+ # A surface map's legend keys its PARTS, not its series (all its regions live under one
278
+ # series), so an entry also joins on a unique part name — giving it a ref to the very
279
+ # element it describes, which is what makes "recolour the block this swatch names" possible.
280
+ by_part: dict[str, list] = {}
281
+ for s in series_entries:
282
+ for prt in (s.get("surface") or {}).get("parts", []):
283
+ if prt.get("part") and prt.get("ref"):
284
+ by_part.setdefault(prt["part"], []).append((s["id"], prt["ref"]))
285
+ # the authoritative join: the artist the entry stands for (tagger.legend_sources) is one
286
+ # of a series' artists (or one of a container's children)
287
+ def series_of_artist(src):
288
+ children = list(getattr(src, "get_children", lambda: [])()) if src is not None else []
289
+ for series, marks in by_series.items():
290
+ for m in marks:
291
+ if any(a is src or any(a is c for c in children) for a in m.artists):
292
+ return _ids.series_root(series)
293
+ return None
294
+
295
+ entries = []
296
+ for k in sorted(legend_entries):
297
+ ent = legend_entries[k]
298
+ e = {}
299
+ matches = by_label.get(ent.get("text"), [])
300
+ if len(matches) == 1:
301
+ e["series"] = matches[0]
302
+ part_matches = by_part.get(ent.get("text"), [])
303
+ if len(part_matches) == 1:
304
+ e["series"], e["part"] = part_matches[0]
305
+ joined = series_of_artist(ent.get("source"))
306
+ if joined is not None and any(s["id"] == joined for s in series_entries):
307
+ e["series"] = joined
308
+ e.pop("part", None) if by_part.get(ent.get("text")) is None else None
309
+ if ent.get("text"):
310
+ e["text"] = ent["text"]
311
+ if ent.get("swatch"):
312
+ e["swatch"] = ent["swatch"]
313
+ if ent.get("label"):
314
+ e["label"] = ent["label"]
315
+ entries.append(e)
316
+ guide_entries.append({"id": "legend", "svgId": "legend", "role": "legend", "entries": entries})
317
+
318
+ # colour scales: one record per control key, listing every group it colours and every colour
319
+ # key drawing it (colorscale.py). Private capture state (keys starting with "_") never leaves.
320
+ import copy as _copy
321
+ scales: dict = {}
322
+ scale_of_mappable: dict = {}
323
+ for m in reg.marks:
324
+ records = ([m.data['color_scale']] if m.data.get('color_scale') else []) + list(m.data.get('color_scales') or [])
325
+ if not records or not _keep(m.gid):
326
+ continue
327
+ for rec in records: # an fp.image carries one scale per channel
328
+ entry = scales.get(rec['id'])
329
+ if entry is None:
330
+ entry = scales[rec['id']] = _copy.deepcopy(rec)
331
+ if m.gid not in entry['mappables']:
332
+ entry['mappables'].append(m.gid)
333
+ scale_of_mappable[m.gid] = entry
334
+ if m.gid in rasterized and entry['recolor'] == 'live':
335
+ entry['recolor'] = 'regenerate'
336
+ for extra in extra_scales: # raw colour-mapped artists: a scale named after their gid
337
+ gid = extra['gid']
338
+ if not _keep(gid):
339
+ continue
340
+ entry = scales.get(extra['record']['id'])
341
+ if entry is None:
342
+ entry = scales[extra['record']['id']] = _copy.deepcopy(extra['record'])
343
+ if gid not in entry['mappables']:
344
+ entry['mappables'].append(gid)
345
+ scale_of_mappable[gid] = entry
346
+ if gid in rasterized and entry['recolor'] == 'live':
347
+ entry['recolor'] = 'regenerate'
348
+
349
+ for g in guides:
350
+ if g.role == 'colorbar':
351
+ payload = {k: v for k, v in g.data.items() if not k.startswith('_')}
352
+ payload['parts'] = [part for part in payload.get('parts', []) if _keep(part['svgId'])]
353
+ if not _keep(payload.get('mappable')): payload.pop('mappable', None)
354
+ scale = scale_of_mappable.get(payload.get('mappable'))
355
+ if scale is not None:
356
+ payload['colorScale'] = scale['id']
357
+ if g.gid not in scale['colorbars']:
358
+ scale['colorbars'].append(g.gid)
359
+ if not scale.get('label') and payload.get('label'):
360
+ scale['label'] = payload['label']
361
+ guide_entries.append({'id': g.gid, 'svgId': g.gid, 'role': g.role, **payload})
362
+
363
+ overlay_entries = []
364
+ for m in overlays:
365
+ if not _keep(m.gid):
366
+ continue
367
+ oe = {"id": m.gid, "svgId": m.gid, "role": m.role}
368
+ mk = mark_kind(m)
369
+ if mk is not None:
370
+ oe["kind"] = mk
371
+ if m.name is not None:
372
+ oe["name"] = m.name
373
+ for key in ("label", "between", "p", "text", "stats", "length", "units"): # carry the annotation text too
374
+ if key in m.data:
375
+ oe[key] = m.data[key]
376
+ if m.gid in rasterized:
377
+ oe["rasterized"] = True
378
+ overlay_entries.append(oe)
379
+ # swept free text → annotation overlays (addressable + animatable like fp.annotation)
380
+ for a in scaffold_annotations:
381
+ oe = {"id": a["id"], "svgId": a["id"], "role": "annotation", "kind": "text"}
382
+ if a.get("text"):
383
+ oe["text"] = a["text"]
384
+ overlay_entries.append(oe)
385
+
386
+ # swept untagged artists (raw ax.plot lines / collections / patches) → "extra" overlays.
387
+ # Kept separate from overlay_entries so they group under a single "extras" node in the parts
388
+ # tree (rather than each becoming a loose ref) while still appearing in the manifest overlays.
389
+ extra_entries = []
390
+ for e in extras:
391
+ ee = {"id": e["id"], "svgId": e["id"], "role": e.get("role", "extra")}
392
+ if e.get("kind"):
393
+ ee["kind"] = e["kind"]
394
+ if e.get("text"):
395
+ ee["text"] = e["text"]
396
+ if e["id"] in rasterized:
397
+ ee["rasterized"] = True
398
+ extra_entries.append(ee)
399
+
400
+ parts = _build_parts_tree(
401
+ series_entries, axes_parts, legend_entries, figure_titles, overlay_entries,
402
+ legend_present, extra_entries, series_kinds, backgrounds,
403
+ )
404
+ for guide in guide_entries:
405
+ if guide['role'] == 'colorbar':
406
+ children = []
407
+ grouped = {'colorbar-tick': ('ticks', 'tick'),
408
+ 'colorbar-tick-label': ('tick-labels', 'tick-label'),
409
+ 'colorbar-gridline': ('gridlines', 'gridline')}
410
+ for role, (suffix, group_role) in grouped.items():
411
+ members = [p['svgId'] for p in guide.get('parts', []) if p['role'] == role]
412
+ if members:
413
+ children.append(_group(guide['id'] + '.' + suffix, group_role, members))
414
+ children.extend(_ref(p['svgId'], p.get('kind'), p['role']) for p in guide.get('parts', [])
415
+ if p['role'] not in grouped)
416
+ parts['children'].append({'id': guide['id'], 'role': 'colorbar', 'kind': 'container',
417
+ 'children': children})
418
+ build = _build_order(series_entries, guide_entries, overlay_entries, reg, figure_titles, extra_entries,
419
+ guide_roles={g.role for g in guides})
420
+
421
+ out = {
422
+ "spec": "fluxplot/manifest",
423
+ "schemaVersion": spec_version,
424
+ "generator": {
425
+ "name": "fluxplot",
426
+ "version": fluxplot_version,
427
+ "matplotlib": mpl_version,
428
+ },
429
+ "plotType": plot_type,
430
+ "svg": svg_filename,
431
+ "size": {"width": vbw, "height": vbh, "unit": "pt"},
432
+ "axes": axes_capture,
433
+ "series": series_entries,
434
+ "guides": guide_entries,
435
+ "overlays": overlay_entries + extra_entries,
436
+ "parts": parts,
437
+ "build": build,
438
+ }
439
+ if scales:
440
+ out["colorScales"] = list(scales.values())
441
+ # ids an older fluxplot gave the same parts (renamed spines; series whose slug changed with
442
+ # the 0.3.2 slug rule): a consumer resolves saved overrides through them. A key is an old id
443
+ # or an old id prefix (a series root stands for every part under it).
444
+ aliases = {g.data["alias"]: g.gid for g in guides if g.data.get("alias") and _keep(g.gid) and g.data["alias"] != g.gid}
445
+ roots_in_use = {_ids.series_root(series) for series in by_series}
446
+ legacy_roots = [_ids.legacy_slugify(series) for series in by_series]
447
+ for series, marks in by_series.items():
448
+ new_root = _ids.series_root(series)
449
+ old_root = _ids.legacy_slugify(series)
450
+ # only an unambiguous rename is aliased: two series that shared the old slug cannot be told apart
451
+ if (old_root != new_root and old_root not in roots_in_use and legacy_roots.count(old_root) == 1
452
+ and any(_keep(m.gid) or m.member_gids for m in marks)):
453
+ aliases[old_root] = new_root
454
+ if aliases:
455
+ out["idAliases"] = aliases
456
+ if style is not None:
457
+ out["style"] = style
458
+ if quality is not None:
459
+ out["quality"] = quality
460
+ if svg_sha256 is not None:
461
+ # checksum of the FINAL postprocessed SVG bytes: deterministic (the SVG is
462
+ # byte-stable) and acyclic (the SVG does not contain the manifest). Consumers use it
463
+ # to detect a stale/mismatched sidecar pair instead of silently degrading (plan §5).
464
+ out["artifact"] = {"svgSha256": svg_sha256}
465
+ return out
466
+
467
+
468
+ def axis_value_labels(mpl_axis, numbers) -> dict:
469
+ """``{"Labels": [...]}`` for a categorical axis (the category each number stands for) or
470
+ ``{"Iso": [...]}`` for a date axis (ISO-8601), else ``{}``."""
471
+ converter = getattr(mpl_axis, "get_converter", lambda: getattr(mpl_axis, "converter", None))()
472
+ module = type(converter).__module__ if converter else ""
473
+ if module == "matplotlib.category":
474
+ mapping = getattr(getattr(mpl_axis, "units", None), "_mapping", None) or {}
475
+ inverse = {float(v): str(k) for k, v in mapping.items()}
476
+ return {"Labels": [None if v is None else inverse.get(float(v)) for v in numbers]}
477
+ if module == "matplotlib.dates":
478
+ from matplotlib.dates import num2date
479
+ out = []
480
+ for v in numbers:
481
+ try:
482
+ out.append(None if v is None else num2date(v).isoformat())
483
+ except (ValueError, OverflowError):
484
+ out.append(None)
485
+ return {"Iso": out}
486
+ return {}
487
+
488
+
489
+ def figure_scope(figure_guides, reg, present=None, rasterized=None): # noqa: ARG001 (present: kept for symmetry)
490
+ """The manifest's figure-scope block from :func:`tagger.autotag_figure`'s tags:
491
+ ``(figure, guide_entries, overlay_entries, parts_children, build_first, build_last)``.
492
+
493
+ ``figure`` is ``{background?, title?, xlabel?, ylabel?, legends: [...], annotations: [...], extras: [...]}``
494
+ with svg ids; a figure legend is a ``guides[]`` entry (``role: legend``, ``entries`` joined to
495
+ series by the artist each stands for) and its parts sit under the figure node of the tree."""
496
+ rasterized = rasterized or set() # (the caller passes only the tags present in the SVG)
497
+ fig: dict = {"legends": [], "annotations": [], "extras": []}
498
+ guide_entries, overlay_entries, children, first, last = [], [], [], [], []
499
+ legends: dict = {}
500
+ aliases: dict = {}
501
+ for g in figure_guides:
502
+ if g.data.get("alias"):
503
+ aliases[g.data["alias"]] = g.gid
504
+ if g.role == "background" and g.text == "figure":
505
+ fig["background"] = g.gid
506
+ children.insert(0, _ref(g.gid, _roles.kind_for_role("background"), "background")) # the ground first
507
+ elif g.role == "title":
508
+ fig[g.data.get("slot", "title")] = g.gid
509
+ children.append(_ref(g.gid, _roles.kind_for_role("title"), "title"))
510
+ first.append(g.gid)
511
+ elif g.role == "legend":
512
+ legends.setdefault(g.gid, {"entries": {}, "background": None})
513
+ elif g.role == "background" and g.data.get("legend"):
514
+ legends.setdefault(g.data["legend"], {"entries": {}, "background": None})["background"] = g.gid
515
+ elif g.role in ("legend-label", "legend-swatch") and g.data.get("legend"):
516
+ ent = legends.setdefault(g.data["legend"], {"entries": {}, "background": None})["entries"].setdefault(g.index, {})
517
+ ent["label" if g.role == "legend-label" else "swatch"] = g.gid
518
+ if g.role == "legend-label":
519
+ ent["text"] = g.text
520
+ if g.data.get("_source") is not None:
521
+ ent["source"] = g.data["_source"]
522
+ elif g.role == "annotation":
523
+ fig["annotations"].append({"id": g.gid, "text": g.text})
524
+ oe = {"id": g.gid, "svgId": g.gid, "role": "annotation", "kind": "text"}
525
+ if g.text:
526
+ oe["text"] = g.text
527
+ overlay_entries.append(oe)
528
+ children.append(_ref(g.gid, "text", "annotation"))
529
+ last.append(g.gid)
530
+ elif g.role == "extra":
531
+ fig["extras"].append(g.gid)
532
+ oe = {"id": g.gid, "svgId": g.gid, "role": "extra"}
533
+ if g.kind:
534
+ oe["kind"] = g.kind
535
+ if g.gid in rasterized:
536
+ oe["rasterized"] = True
537
+ overlay_entries.append(oe)
538
+ children.append(_ref(g.gid, g.kind, "extra"))
539
+ last.append(g.gid)
540
+
541
+ def series_id_of(src):
542
+ kids = list(getattr(src, "get_children", lambda: [])()) if src is not None else []
543
+ for m in reg.marks:
544
+ gid = m.gid or (m.member_gids[0] if m.member_gids else None) # bars have members only
545
+ if m.series is None or not gid:
546
+ continue
547
+ if any(a is src or any(a is c for c in kids) for a in m.artists):
548
+ root = _ids.series_root(m.series)
549
+ prefix = gid[: gid.index(root)] if root in gid else ""
550
+ return prefix + root
551
+ return None
552
+
553
+ for gid, leg in legends.items():
554
+ entries, kids = [], []
555
+ if leg["background"]:
556
+ kids.append(_ref(leg["background"], _roles.kind_for_role("background"), "background"))
557
+ for k in sorted(leg["entries"]):
558
+ ent = leg["entries"][k]
559
+ e = {}
560
+ joined = series_id_of(ent.get("source"))
561
+ if joined is not None:
562
+ e["series"] = joined
563
+ for key in ("text", "swatch", "label"):
564
+ if ent.get(key):
565
+ e[key] = ent[key]
566
+ entries.append(e)
567
+ ek = []
568
+ if ent.get("swatch"):
569
+ ek.append(_ref(ent["swatch"], _roles.kind_for_role("legend-swatch"), "legend-swatch"))
570
+ if ent.get("label"):
571
+ ek.append(_ref(ent["label"], _roles.kind_for_role("legend-label"), "legend-label"))
572
+ node = {"id": f"{gid}.entry.{k}", "role": "legend-entry", "kind": "container", "children": ek}
573
+ if ent.get("text"):
574
+ node["label"] = ent["text"]
575
+ kids.append(node)
576
+ fig["legends"].append(gid)
577
+ guide_entries.append({"id": gid, "svgId": gid, "role": "legend", "entries": entries})
578
+ children.append({"id": gid, "role": "legend", "kind": "container", "children": kids})
579
+ fig = {k: v for k, v in fig.items() if v not in ([], None)}
580
+ return fig, guide_entries, overlay_entries, children, first, last, aliases
581
+
582
+
583
+ def _organize_guides(guides):
584
+ """Bucket the flat GuideTag list into per-axis parts + legend entries + the figure title."""
585
+ axes: dict = {}
586
+ legend_entries: dict = {}
587
+ figure_titles: list = [] # left/center/right + suptitle can coexist (no last-wins)
588
+ annotations: list = [] # swept free text → addressable annotation overlays
589
+ extras: list = [] # swept untagged artists → addressable "extra" content
590
+ backgrounds: dict = {} # "axes" / "figure" / "legend" → the background patch's gid
591
+ for g in guides:
592
+ if g.data.get("scope") == "figure":
593
+ continue # figure-scope tags are organized by figure_scope()
594
+ if g.role == "background" and g.axis is None:
595
+ backgrounds[g.text] = g.gid
596
+ elif g.role == "axis":
597
+ axes.setdefault(g.axis, {})["gid"] = g.gid
598
+ elif g.role == "axis-title":
599
+ axes.setdefault(g.axis, {})["title"] = g.gid
600
+ elif g.role == "tick-label":
601
+ axes.setdefault(g.axis, {}).setdefault("ticklabels", []).append(g.gid)
602
+ elif g.role == "tick":
603
+ axes.setdefault(g.axis, {}).setdefault("ticks", []).append(g.gid)
604
+ elif g.role == "gridline":
605
+ axes.setdefault(g.axis, {}).setdefault("gridlines", []).append(g.gid)
606
+ elif g.role in ("spine", "background"):
607
+ axes.setdefault(g.axis, {}).setdefault("spines", []).append(g.gid)
608
+ elif g.role == "legend-swatch":
609
+ legend_entries.setdefault(g.index, {})["swatch"] = g.gid
610
+ if g.data.get("_source") is not None:
611
+ legend_entries[g.index]["source"] = g.data["_source"]
612
+ elif g.role == "legend-label":
613
+ legend_entries.setdefault(g.index, {})["label"] = g.gid
614
+ legend_entries[g.index]["text"] = g.text
615
+ if g.data.get("_source") is not None:
616
+ legend_entries[g.index]["source"] = g.data["_source"]
617
+ elif g.role == "title":
618
+ figure_titles.append(g.gid)
619
+ elif g.role == "annotation":
620
+ annotations.append({"id": g.gid, "text": g.text})
621
+ elif g.role == "extra":
622
+ extras.append({"id": g.gid, "kind": g.kind})
623
+ elif g.role == "label" and g.gid.startswith("extra."): # a swept anchored box's text
624
+ extras.append({"id": g.gid, "kind": g.kind, "role": "label", "text": g.text})
625
+ return axes, legend_entries, figure_titles, annotations, extras, backgrounds
626
+
627
+
628
+ def _group(gid: str, group_role: str, members: list) -> dict:
629
+ """A manifest-only node grouping sibling leaves so a consumer can act on all at once.
630
+
631
+ The node mirrors its members' data-kind (a group of tick labels edits like text, a
632
+ group of gridlines like a line) so consumers can pick property sets without the DOM, and
633
+ names the members' role (``memberRole``) so each member leaf is classified without
634
+ parsing its id.
635
+ """
636
+ node = {"id": gid, "role": "group", "groupRole": group_role, "memberRole": group_role,
637
+ "members": list(members)}
638
+ k = _roles.kind_for_role(group_role)
639
+ if k is not None:
640
+ node["kind"] = k
641
+ return node
642
+
643
+
644
+ def _ref(gid: str, kind=None, role=None) -> dict:
645
+ """A leaf reference into the SVG, carrying its role and the data-kind hint when known."""
646
+ node = {"ref": gid}
647
+ if role is not None:
648
+ node["role"] = role
649
+ if kind is not None:
650
+ node["kind"] = kind
651
+ return node
652
+
653
+
654
+ def _build_parts_tree(
655
+ series_entries, axes_parts, legend_entries, figure_titles, overlay_entries, legend_present,
656
+ extra_entries=(), series_kinds=None, backgrounds=None,
657
+ ) -> dict:
658
+ series_kinds = series_kinds or {}
659
+ backgrounds = backgrounds or {}
660
+ plot_children = []
661
+ if backgrounds.get("axes"): # the ground comes first, as it is drawn
662
+ plot_children.append(_ref(backgrounds["axes"], _roles.kind_for_role("background"), "background"))
663
+
664
+ # axes → real <g id="axis.x"> nodes, each with spine + grouped ticks/labels/gridlines + title
665
+ for which in ("x", "y", "z", "x2", "y2"):
666
+ ap = axes_parts.get(which)
667
+ if not ap:
668
+ continue
669
+ kids = []
670
+ for sp in ap.get("spines", []):
671
+ kids.append(_ref(sp, _roles.kind_for_role("spine"), "spine"))
672
+ if ap.get("ticks"):
673
+ kids.append(_group(f"axis.{which}.ticks", "tick", ap["ticks"]))
674
+ if ap.get("ticklabels"):
675
+ kids.append(_group(f"axis.{which}.tick-labels", "tick-label", ap["ticklabels"]))
676
+ if ap.get("gridlines"):
677
+ kids.append(_group(f"axis.{which}.gridlines", "gridline", ap["gridlines"]))
678
+ if ap.get("title"):
679
+ kids.append(_ref(ap["title"], _roles.kind_for_role("axis-title"), "axis-title"))
680
+ plot_children.append(
681
+ {
682
+ "id": ap.get("gid", f"axis.{which}"),
683
+ "role": "axis",
684
+ "axis": which,
685
+ "kind": "container",
686
+ "children": kids,
687
+ }
688
+ )
689
+
690
+ # series → line + grouped points/bars + ANY other tagged role. The generic
691
+ # tail covers area/errorbar/box AND custom plot kinds (x-violin, x-heatmap-cell,
692
+ # x-stem, x-trajectory, x-contour, …) so every drawn series part is addressable
693
+ # and no series becomes a childless phantom node.
694
+ for s in series_entries:
695
+ kinds = series_kinds.get(s["id"], {})
696
+ kids = []
697
+ by_role = {}
698
+ for component in s["components"]:
699
+ by_role.setdefault(component["role"], []).append(component)
700
+ for role, components in by_role.items():
701
+ kind = kinds.get(role, _roles.kind_for_role(role))
702
+ if role == "point" or any(c.get("members") for c in components):
703
+ for c in components:
704
+ if c.get("members"):
705
+ node = _group(c.get("groupId", c["svgId"]), role, c["members"])
706
+ node["memberRole"] = c.get("memberRole", role)
707
+ mk = _roles.kind_for_role(node["memberRole"])
708
+ if mk is not None:
709
+ node["kind"] = mk
710
+ kids.append(node)
711
+ else:
712
+ kids.append(_ref(c["svgId"], kind, role))
713
+ elif role == "bar" or role in COMPOSITE_ROLES or len(components) > 1:
714
+ plural = "bars" if role == "bar" else COMPOSITE_ROLES.get(role, role + "-parts")
715
+ kids.append(_group(s["id"] + "." + plural, role, [c["svgId"] for c in components]))
716
+ else:
717
+ kids.append(_ref(components[0]["svgId"], kind, role))
718
+ node = {"id": s["id"], "role": "series", "kind": "container", "children": kids,
719
+ "label": s.get("label") or s["name"]}
720
+ plot_children.append(node)
721
+
722
+ for o in overlay_entries:
723
+ plot_children.append(_ref(o["svgId"], o.get("kind"), o.get("role")))
724
+
725
+ # untagged user-drawn artists → one "extras" group so a consumer can act on all at once
726
+ if extra_entries:
727
+ plot_children.append(_group("extras", "extra", [e["svgId"] for e in extra_entries]))
728
+
729
+ figure_children = []
730
+ if backgrounds.get("figure"):
731
+ figure_children.append(_ref(backgrounds["figure"], _roles.kind_for_role("background"), "background"))
732
+ figure_children.append(
733
+ {"id": "plot-area", "role": "plot-area", "kind": "container", "children": plot_children}
734
+ )
735
+ if legend_present:
736
+ leg_kids = []
737
+ if backgrounds.get("legend"):
738
+ leg_kids.append(_ref(backgrounds["legend"], _roles.kind_for_role("background"), "background"))
739
+ for k in sorted(legend_entries):
740
+ ent = legend_entries[k]
741
+ ek = []
742
+ if ent.get("swatch"):
743
+ ek.append(_ref(ent["swatch"], _roles.kind_for_role("legend-swatch"), "legend-swatch"))
744
+ if ent.get("label"):
745
+ ek.append(_ref(ent["label"], _roles.kind_for_role("legend-label"), "legend-label"))
746
+ entry = {"id": f"legend.entry.{k}", "role": "legend-entry", "kind": "container", "children": ek}
747
+ if ent.get("text"):
748
+ entry["label"] = ent["text"]
749
+ leg_kids.append(entry)
750
+ figure_children.append(
751
+ {"id": "legend", "role": "legend", "kind": "container", "children": leg_kids}
752
+ )
753
+ for t in figure_titles:
754
+ figure_children.append(_ref(t, _roles.kind_for_role("title"), "title"))
755
+ return {"id": "figure", "role": "figure", "kind": "container", "children": figure_children}
756
+
757
+
758
+ def _build_order(series_entries, guide_entries, overlay_entries, reg, figure_titles=(), extra_entries=(),
759
+ guide_roles=()) -> dict:
760
+ order = []
761
+ for g in guide_entries:
762
+ if g["role"] == "axis":
763
+ order.append(g.get("svgId", g["id"]))
764
+ order.extend(g['svgId'] for g in guide_entries if g['role'] == 'colorbar')
765
+ order.extend(figure_titles) # titles reveal with the axes (phase 0)
766
+ if "gridline" in guide_roles: # the legacy figure-wide token, only when gridlines exist
767
+ order.append("gridlines")
768
+ # Use the same component inventory as the tree. Unknown/extension roles are
769
+ # ordinary drawable parts, not exceptions silently excluded from animation.
770
+ for s in series_entries:
771
+ order.extend(c["svgId"] for c in s["components"] if c["role"] == "line")
772
+ for s in series_entries:
773
+ order.extend(c["svgId"] for c in s["components"] if c["role"] != "line")
774
+ if any(g["role"] == "legend" for g in guide_entries):
775
+ order.append("legend")
776
+ for o in overlay_entries:
777
+ order.append(o["svgId"])
778
+ for e in extra_entries:
779
+ order.append(e["svgId"])
780
+
781
+ # every role a consumer may animate: the marks' roles, their members' roles (hexes, cells,
782
+ # contour bands) and the scaffold/guide roles that are present (legend, colorbar, title, …)
783
+ roles_present = {m.role for m in reg.marks} | {"axis", "gridline"} | set(guide_roles)
784
+ for s in series_entries:
785
+ roles_present.update(c["memberRole"] for c in s["components"] if c.get("memberRole"))
786
+ if extra_entries:
787
+ roles_present.add("extra")
788
+ return {"order": list(dict.fromkeys(order)), "presets": _presets.presets_for(sorted(roles_present))}