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
@@ -0,0 +1,638 @@
1
+ """Post-render SVG pass (lxml): inject ``data-*`` + canonicalize.
2
+
3
+ matplotlib emits only ``id`` (from the gids we set) — never arbitrary ``data-*``. So this pass is
4
+ *mechanically required*, and it is NOT the rejected "post-hoc heuristic surgery": the meaning was
5
+ captured at birth (we set ``id="control.line"`` before rendering), so this is an **exact structural
6
+ join on ids we authored** + lossless annotation. It never infers anything from pixels.
7
+
8
+ It: (1) joins by our gids and injects ``data-role``/``data-series``/``data-index``/``data-x``/
9
+ ``data-y``; (2) splits the per-series points group into addressable per-point ``<use>``; (3) renames
10
+ matplotlib's ``figure_1``/``axes_1`` wrappers to ``figure``/``plot-area``; (4) strips volatile
11
+ metadata (comments, ``<metadata>``); (5) deterministically serializes.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from lxml import etree
16
+
17
+ from . import raster as _raster
18
+ from .descriptors import Mark, mark_kind
19
+ from .roles import kind_for_role
20
+
21
+ SVG = "http://www.w3.org/2000/svg"
22
+ XLINK = "http://www.w3.org/1999/xlink"
23
+
24
+
25
+ def _fmt(v) -> str:
26
+ if isinstance(v, bool):
27
+ return str(v)
28
+ if isinstance(v, (int, float)):
29
+ return repr(v)
30
+ return str(v)
31
+
32
+
33
+ def _set(el, **attrs) -> None:
34
+ for k, v in attrs.items():
35
+ if v is None:
36
+ continue
37
+ el.set(k.replace("_", "-"), _fmt(v))
38
+
39
+
40
+ def postprocess(svg_bytes: bytes, reg, guides, plot_type: str, raster_items=(), check_ids=True,
41
+ extra_scales=(), style_tokens=None, theme_vars=False):
42
+ """Return ``(processed_svg_bytes, warnings, present)``.
43
+
44
+ ``extra_scales`` are the anonymous colour scales of raw colour-mapped artists
45
+ (``fields.anonymous_scales``): their groups get ``data-color-scale`` / ``data-paint`` too.
46
+ ``style_tokens`` (``style.current_tokens()``) drive the semantic ink tags on scaffold
47
+ elements; ``theme_vars`` additionally rewrites those paints to ``var(--fx-<token>, <hex>)``.
48
+ """
49
+ parser = etree.XMLParser(remove_blank_text=True)
50
+ root = etree.fromstring(svg_bytes, parser)
51
+ warnings: list[str] = []
52
+
53
+ # 1. strip volatile metadata
54
+ etree.strip_tags(root, etree.Comment)
55
+ for md in root.findall(f"{{{SVG}}}metadata"):
56
+ md.getparent().remove(md)
57
+
58
+ # 1b. give rasterized layers their gids back BEFORE anything joins on ids — matplotlib
59
+ # drops the gid when it rasterizes an artist (see raster.py), and every step below is an
60
+ # exact join on the ids we authored.
61
+ _raster.reattach(root, raster_items, warnings)
62
+
63
+ id_map = {el.get("id"): el for el in root.iter() if el.get("id")}
64
+
65
+ # 2. rename matplotlib's structural wrappers → semantic roots
66
+ _rename(id_map, "figure_1", "figure", "figure")
67
+ if not any(g == "plot-area" or g.endswith(".plot-area") for g in id_map):
68
+ _rename(id_map, "axes_1", "plot-area", "plot-area")
69
+ for gid, el in id_map.items():
70
+ if gid == "plot-area" or gid.endswith(".plot-area"):
71
+ _set(el, data_role="plot-area", data_kind="container")
72
+
73
+ # 3. root markers
74
+ root.set("data-fluxplot", "1")
75
+ root.set("data-plot-type", plot_type)
76
+
77
+ # 4. inject data-* per Mark
78
+ for m in reg.marks:
79
+ if m.data.get('contour_legacy'):
80
+ _group_legacy_contour(m, id_map)
81
+ if m.data.get('cells') or m.data.get('contour_paths') or m.data.get('field_names'):
82
+ _inject_field(m, id_map, warnings)
83
+ if m.role == "point":
84
+ _inject_points(m, id_map, warnings)
85
+ elif m.role == "bar":
86
+ _inject_indexed(m, id_map, role="bar")
87
+ elif m.series is not None:
88
+ # every member (a composite like errorbar renders the centre line + caps +
89
+ # bar segments as numbered siblings) carries role/series/kind, not just the first
90
+ kind = mark_kind(m)
91
+ for gid in m.member_gids or ([m.gid] if m.gid else []):
92
+ el = id_map.get(gid)
93
+ if el is not None:
94
+ _set(el, data_role=m.role, data_series=m.series, data_kind=kind)
95
+ else:
96
+ _inject_overlay(m, id_map)
97
+
98
+ # 4b. colour scales: every group a scale colours names it and says which paint properties
99
+ # the scale drives; each coloured element already carries its value (see _inject_field /
100
+ # _inject_points), so a consumer can recolour the plot from the manifest's colorScales alone.
101
+ for m in reg.marks:
102
+ records = ([m.data['color_scale']] if m.data.get('color_scale') else []) + list(m.data.get('color_scales') or [])
103
+ el = id_map.get(m.gid) if records else None
104
+ if el is not None: # an fp.image names every channel's scale, space-separated
105
+ _set(el, data_color_scale=' '.join(r['id'] for r in records), data_paint=m.data.get('color_paint'))
106
+ for extra in extra_scales:
107
+ el = id_map.get(extra['gid'])
108
+ if el is not None:
109
+ _set(el, data_color_scale=extra['record']['id'], data_paint=extra.get('paint'))
110
+
111
+ # 4c. colour keys: the solids become one exact gradient rect instead of N quads
112
+ _vectorize_colorbars(root, guides, id_map, warnings)
113
+
114
+ # 5. inject data-role (+ data-kind hint) on scaffold/guides
115
+ for g in guides:
116
+ el = id_map.get(g.gid)
117
+ if el is not None:
118
+ _set(
119
+ el,
120
+ data_role=g.role,
121
+ data_axis=g.axis,
122
+ data_index=g.index,
123
+ data_series=g.series,
124
+ data_kind=g.kind,
125
+ )
126
+
127
+ # 6. dereference tick <use> → real <path> so Flux's draw-on preset can measure/animate
128
+ # them (a <use> has no measurable path length). Scoped to axis/colorbar tick
129
+ # groups; point <use> elements (which animate via opacity/transform) are untouched.
130
+ _deref_ticks(root)
131
+
132
+ # 6b. semantic ink: which theme token painted each scaffold / overlay element (B1). After
133
+ # the tick dereference, so every tick is a real path carrying its own merged paint.
134
+ if style_tokens:
135
+ _tag_inks(reg, guides, id_map, style_tokens, theme_vars)
136
+
137
+ # The set of ids that actually survived into the SVG (computed AFTER injection
138
+ # so per-point <use> ids are included). matplotlib culls boundary ticks/
139
+ # gridlines at draw and omits empty axis titles even though the artists carry a
140
+ # gid — the manifest must reference only what's really here, so callers prune
141
+ # guides/members against this set (else the X-Ray shows dead nodes).
142
+ all_ids = [el.get("id") for el in root.iter() if el.get("id")]
143
+ present = set(all_ids)
144
+ if check_ids and len(all_ids) != len(present):
145
+ from collections import Counter
146
+ duplicates = [gid for gid, count in Counter(all_ids).items() if count > 1]
147
+ raise ValueError('duplicate SVG IDs: ' + ', '.join(duplicates[:10]))
148
+
149
+ return _serialize(root), warnings, present
150
+
151
+
152
+ def _rename(id_map, old, new, role) -> None:
153
+ el = id_map.pop(old, None)
154
+ if el is not None:
155
+ el.set("id", new)
156
+ el.set("data-role", role)
157
+ _set(el, data_kind=kind_for_role(role))
158
+ id_map[new] = el
159
+
160
+
161
+ def _inject_points(m: Mark, id_map, warnings) -> None:
162
+ shared = m.data.get("point_group") # a hue series sharing another series' collection
163
+ group = id_map.get(shared or m.gid)
164
+ if group is None:
165
+ return
166
+ # the group's kind mirrors its members (edit the group ⇒ restyle every point); a shared
167
+ # group names no single series
168
+ _set(group, data_series=None if shared or m.data.get("point_subset") else m.series, data_kind=kind_for_role("point"))
169
+ if group.get("data-rasterized") == "1":
170
+ # A rasterized point cloud IS one <image> — there are no per-point <use> nodes to
171
+ # split, and that is the intended outcome, not a shortfall. The series stays
172
+ # addressable as a whole; no warning (see raster.py).
173
+ _set(group, data_role="point")
174
+ return
175
+ n = len(m.member_gids)
176
+ positions = m.data.get("point_positions")
177
+ expected = m.data.get("point_drawn", n) if positions is not None else n
178
+ members = list(group.iter(f"{{{SVG}}}use"))
179
+ if len(members) != expected:
180
+ # matplotlib's SVG backend shares one marker <path> through N <use> only when every
181
+ # marker has the same transform. Per-point sizes (a bubble chart, ``s=`` an array) make
182
+ # it fall back to drawing each marker as a direct <path> child of the collection group
183
+ # (never wrapped, never in <defs>); those are the same N points in the same order.
184
+ paths = [el for el in group if el.tag == f"{{{SVG}}}path"]
185
+ if len(paths) == expected:
186
+ members = paths
187
+ else:
188
+ warnings.append(
189
+ f"points '{m.gid}': {len(members)} <use> / {len(paths)} <path> vs {expected} data "
190
+ "points — skipping per-point ids"
191
+ )
192
+ _set(group, data_role="point")
193
+ return
194
+ if positions is not None: # this mark's members are a subset of the collection's points
195
+ members = [members[k] for k in positions]
196
+ xs = list(m.x) if m.x is not None else [None] * n
197
+ ys = list(m.y) if m.y is not None else [None] * n
198
+ cs = m.data.get("c") # the colour-mapped value of each point (fp.scatter c=)
199
+ alphas = m.data.get("alpha_values") # the confidence-like value behind each point's opacity
200
+ for k, use_el in enumerate(members):
201
+ use_el.set("id", m.member_gids[k])
202
+ i = m.member_indices[k]
203
+ _set(
204
+ use_el,
205
+ data_role="point",
206
+ data_series=m.series,
207
+ data_index=i,
208
+ data_x=xs[i],
209
+ data_y=ys[i],
210
+ data_kind=kind_for_role("point"),
211
+ )
212
+ if cs is not None and i < len(cs):
213
+ _set_value(use_el, cs[i])
214
+ if alphas is not None and i < len(alphas) and alphas[i] is not None:
215
+ use_el.set("data-alpha-value", _fmt(alphas[i]))
216
+
217
+
218
+ def _inject_indexed(m: Mark, id_map, role: str) -> None:
219
+ xs = list(m.x) if m.x is not None else [None] * len(m.member_gids)
220
+ ys = list(m.y) if m.y is not None else [None] * len(m.member_gids)
221
+ keys = (m.data.get("bar") or {}).get("keys") if role == "bar" else None
222
+ for k, gid in enumerate(m.member_gids):
223
+ el = id_map.get(gid)
224
+ if el is None:
225
+ continue
226
+ _set(
227
+ el,
228
+ data_role=role,
229
+ data_series=m.series,
230
+ data_index=k,
231
+ data_x=xs[k] if k < len(xs) else None,
232
+ data_y=ys[k] if k < len(ys) else None,
233
+ data_kind=kind_for_role(role),
234
+ )
235
+ if keys and k < len(keys): # the stable member key: the category under the bar
236
+ el.set("data-key", str(keys[k]))
237
+
238
+
239
+ def _inject_overlay(m: Mark, id_map) -> None:
240
+ el = id_map.get(m.gid)
241
+ if el is not None:
242
+ _set(el, data_role=m.role, data_name=m.name, data_kind=mark_kind(m))
243
+ label_gid = m.data.get("label_gid")
244
+ if label_gid is not None:
245
+ lab = id_map.get(label_gid)
246
+ if lab is not None:
247
+ _set(lab, data_role="label", data_name=m.name, data_kind=kind_for_role("label"))
248
+
249
+
250
+ def _href(el):
251
+ """Read an SVG reference from either ``xlink:href`` or a plain ``href`` attribute."""
252
+ return el.get(f"{{{XLINK}}}href") or el.get("href")
253
+
254
+
255
+ def _parse_style(s: str | None) -> dict:
256
+ out: dict = {}
257
+ if not s:
258
+ return out
259
+ for decl in s.split(";"):
260
+ decl = decl.strip()
261
+ if not decl:
262
+ continue
263
+ key, _, val = decl.partition(":")
264
+ key = key.strip()
265
+ if key:
266
+ out[key] = val.strip()
267
+ return out
268
+
269
+
270
+ def _merge_style(target_style: str | None, use_style: str | None) -> str | None:
271
+ """Merge the referenced path's style *under* the ``<use>``'s own style (use wins).
272
+
273
+ Deterministic: target declarations first (insertion order), then any the use adds/overrides.
274
+ """
275
+ merged = _parse_style(target_style)
276
+ merged.update(_parse_style(use_style))
277
+ if not merged:
278
+ return None
279
+ return "; ".join(f"{k}: {v}" for k, v in merged.items())
280
+
281
+
282
+ def _deref_ticks(root) -> None:
283
+ """Inline ``<use>`` elements in axis and colorbar tick groups as measurable paths.
284
+
285
+ matplotlib renders a tick as ``<g data-role="tick"><g><use href="#markerPath"/></g></g>`` where
286
+ only the first tick of an axis carries the shared ``<defs><path>``. A ``<use>`` has no measurable
287
+ geometry, so Flux's draw-on (stroke-dashoffset over path length) can't animate it. We resolve the
288
+ referenced path, inline it (folding the use's x/y offset into a ``translate`` transform and merging
289
+ styles, use wins), unwrap the now-bare anonymous ``<g>``, and drop any tick-local ``<defs>`` whose
290
+ path id is no longer referenced anywhere in the document.
291
+ """
292
+ # Document-wide map of path id → element (the target may live in a *different* tick's defs).
293
+ path_by_id = {p.get("id"): p for p in root.iter(f"{{{SVG}}}path") if p.get("id")}
294
+
295
+ tick_groups = [el for el in root.iter() if el.get("data-role") in ("tick", "colorbar-tick")]
296
+
297
+ for tg in tick_groups:
298
+ for use in list(tg.iter(f"{{{SVG}}}use")):
299
+ href = _href(use)
300
+ if not href or not href.startswith("#"):
301
+ continue
302
+ target = path_by_id.get(href[1:])
303
+ if target is None:
304
+ continue
305
+ new = etree.Element(f"{{{SVG}}}path")
306
+ new.set("d", target.get("d", ""))
307
+ transforms = []
308
+ if use.get("transform"):
309
+ transforms.append(use.get("transform"))
310
+ x, y = use.get("x"), use.get("y")
311
+ if x is not None or y is not None:
312
+ transforms.append(f"translate({x or 0} {y or 0})")
313
+ if transforms:
314
+ new.set("transform", " ".join(transforms))
315
+ style = _merge_style(target.get("style"), use.get("style"))
316
+ if style:
317
+ new.set("style", style)
318
+ parent = use.getparent()
319
+ idx = parent.index(use)
320
+ parent.remove(use)
321
+ parent.insert(idx, new)
322
+ # Unwrap an anonymous <g> wrapper with no attributes and no other children.
323
+ if parent.tag == f"{{{SVG}}}g" and not parent.attrib and len(parent) == 1:
324
+ gp = parent.getparent()
325
+ if gp is not None:
326
+ gidx = gp.index(parent)
327
+ gp.remove(parent)
328
+ gp.insert(gidx, new)
329
+
330
+ # Drop tick-local <defs> paths that nothing references anymore (careful: a <use> elsewhere
331
+ # in the document — e.g. a point cloud — must keep its defs).
332
+ referenced = set()
333
+ for use in root.iter(f"{{{SVG}}}use"):
334
+ href = _href(use)
335
+ if href and href.startswith("#"):
336
+ referenced.add(href[1:])
337
+ for tg in tick_groups:
338
+ for defs in list(tg.iter(f"{{{SVG}}}defs")):
339
+ for p in list(defs):
340
+ pid = p.get("id")
341
+ if p.tag == f"{{{SVG}}}path" and pid is not None and pid not in referenced:
342
+ defs.remove(p)
343
+ if len(defs) == 0:
344
+ dp = defs.getparent()
345
+ if dp is not None:
346
+ dp.remove(defs)
347
+
348
+
349
+ def _serialize(root) -> bytes:
350
+ body = etree.tostring(root, pretty_print=True, encoding="unicode")
351
+ return ('<?xml version="1.0" encoding="utf-8" standalone="no"?>\n' + body).encode("utf-8")
352
+
353
+
354
+ def _set_value(el, value) -> None:
355
+ """The value an element was coloured by: ``data-value``, or ``data-missing`` for a gap."""
356
+ if value is None or (isinstance(value, float) and value != value):
357
+ _set(el, data_missing=1)
358
+ else:
359
+ _set(el, data_value=float(value))
360
+
361
+
362
+ def _level_text(v) -> str:
363
+ """A contour level boundary for ``data-level-low`` / ``-high``: matplotlib stands in
364
+ ``±1e250`` for an extend band's open end — say ``-inf`` / ``inf`` instead."""
365
+ v = float(v)
366
+ if abs(v) >= 1e249:
367
+ return "-inf" if v < 0 else "inf"
368
+ return repr(v)
369
+
370
+
371
+ def _cell_bounds(artist, rows, cols):
372
+ """``[(x0, x1, y0, y1)]`` per cell of a QuadMesh in data units (row-major), or ``None``."""
373
+ import numpy as _np
374
+ coords = getattr(artist, 'get_coordinates', None)
375
+ if coords is None:
376
+ return None
377
+ c = _np.asarray(coords(), dtype=float)
378
+ if c.shape[:2] != (rows + 1, cols + 1):
379
+ return None
380
+ out = []
381
+ for r in range(rows):
382
+ for k in range(cols):
383
+ corners = c[r:r + 2, k:k + 2].reshape(-1, 2)
384
+ xs, ys = corners[:, 0], corners[:, 1]
385
+ out.append((float(_np.nanmin(xs)), float(_np.nanmax(xs)), float(_np.nanmin(ys)), float(_np.nanmax(ys))))
386
+ return out
387
+
388
+
389
+ def _inject_field(mark, id_map, warnings):
390
+ group = id_map.get(mark.gid)
391
+ if group is None or group.get('data-rasterized') == '1': return
392
+ # Direct paths are emitted in row-major QuadMesh order or ContourSet level
393
+ # order. Exclude definitions/clip paths; reject any backend count mismatch.
394
+ paths = group.findall(f'{{{SVG}}}path')
395
+ field = mark.data['field']
396
+ artist = mark.data['field_artist']
397
+ base, role, attrs = mark.gid, 'cell' if mark.data.get('cells') else 'contour-level', None
398
+ cell_values = levels = bounds = None
399
+ if mark.data.get('field_names'):
400
+ # Helper-authored member names (e.g. hexmatrix ``hex.<row>.<col>``), in path order, with
401
+ # per-member data-* attributes. Members hang off the series root, beside the layer id.
402
+ names = mark.data['field_names']
403
+ count = len(names)
404
+ role = mark.data.get('field_member_role', 'cell')
405
+ attrs = mark.data.get('field_attrs')
406
+ if mark.data.get('field_member_prefix'):
407
+ base = mark.gid.rsplit('.', 1)[0] + '.' + mark.data['field_member_prefix']
408
+ elif mark.data.get('cells'):
409
+ rows, cols = field['shape']
410
+ count = rows * cols
411
+ names = [f'cell.{i // cols}.{i % cols}' for i in range(count)]
412
+ import numpy as _np
413
+ arr = _np.ma.masked_invalid(_np.ma.asarray(artist.get_array(), dtype=float)).reshape(-1)
414
+ cell_values = [None if m else float(v) for v, m in zip(arr.filled(_np.nan), _np.ma.getmaskarray(arr))]
415
+ bounds = _cell_bounds(artist, rows, cols)
416
+ else:
417
+ count = len(artist.get_paths())
418
+ names = [f'level.{i}' for i in range(count)]
419
+ # a band is coloured by its layer value (the midpoint of its bounding levels, an extend
420
+ # band by matplotlib's far stand-in); a line by its level — exactly what data-value says
421
+ cvalues = [float(v) for v in getattr(artist, 'cvalues', [])]
422
+ bounds = [float(v) for v in getattr(artist, '_levels', [])]
423
+ if artist.filled and len(bounds) == count + 1:
424
+ levels = list(zip(bounds[:-1], bounds[1:]))
425
+ cell_values = cvalues if len(cvalues) == count else None
426
+ if len(paths) != count:
427
+ warnings.append(f"field '{mark.gid}': backend path count differs; keeping layer identity")
428
+ return
429
+ members = []
430
+ bins = (mark.data.get('hexmatrix') or {}).get('bins')
431
+ alpha_values = mark.data.get('alpha_values')
432
+ for i, (path, name) in enumerate(zip(paths, names)):
433
+ gid = base + '.' + name
434
+ path.set('id', gid)
435
+ _set(path, data_role=role, data_index=i, data_series=mark.series, data_kind='shape')
436
+ if attrs:
437
+ _set(path, **attrs[i])
438
+ elif mark.data.get('cells'):
439
+ _set(path, data_row=i // cols, data_column=i % cols, data_key=f'{i // cols}.{i % cols}')
440
+ if bounds is not None: # the cell's data-space box: what a re-projected view moves
441
+ x0, x1, y0, y1 = bounds[i]
442
+ _set(path, data_x0=x0, data_x1=x1, data_y0=y0, data_y1=y1)
443
+ if cell_values is not None:
444
+ _set_value(path, cell_values[i])
445
+ if alpha_values is not None and i < len(alpha_values) and alpha_values[i] is not None:
446
+ path.set('data-alpha-value', _fmt(alpha_values[i]))
447
+ if levels is not None:
448
+ path.set('data-level-low', _level_text(levels[i][0]))
449
+ path.set('data-level-high', _level_text(levels[i][1]))
450
+ if bins is not None and i < len(bins):
451
+ bins[i]['svgId'] = gid
452
+ members.append(gid)
453
+ id_map[gid] = path
454
+ mark.data['field_members'] = members
455
+
456
+
457
+ # ---------------------------------------------------------------------------------------------
458
+ # semantic ink (B1): a scaffold element painted with a theme token says which one
459
+ # ---------------------------------------------------------------------------------------------
460
+ #: scaffold and overlay roles whose paint comes from the theme, and the token each prefers when
461
+ #: several tokens share one hex (ink and label are both #100f0f in the light theme)
462
+ INK_ROLES = {
463
+ "axis-title": "label", "tick-label": "ink", "title": "ink", "subtitle": "ink", "legend-label": "ink",
464
+ "annotation": "ink", "tick": "tick", "spine": "axis", "gridline": "grid", "axis": "axis",
465
+ "colorbar-label": "label", "colorbar-tick-label": "ink", "colorbar-tick": "tick", "colorbar-outline": "axis",
466
+ "colorbar-gridline": "grid", "significance-bracket": "ink", "reference-line": None, "label": "ink",
467
+ "scalebar": "ink",
468
+ }
469
+ _BACKGROUND_TOKEN = {"axes": "plot", "figure": "paper", "legend": "plot"}
470
+ _DRAWABLE = {f"{{{SVG}}}{t}" for t in ("path", "text", "rect", "line", "polygon", "polyline", "circle", "ellipse", "use")}
471
+
472
+
473
+ def _paint(el, prop):
474
+ """An element's own fill / stroke as lowercase hex, ``"none"``, or ``None`` when unset (a
475
+ <text> or <path> without a fill declaration is painted black: the SVG default)."""
476
+ value = _parse_style(el.get("style")).get(prop) or el.get(prop)
477
+ if value is None:
478
+ return "#000000" if (prop == "fill" and el.tag in (f"{{{SVG}}}text", f"{{{SVG}}}path", f"{{{SVG}}}rect")) else None
479
+ value = value.strip().lower()
480
+ if value == "none":
481
+ return "none"
482
+ if value.startswith("url("):
483
+ return None
484
+ try:
485
+ from matplotlib.colors import to_hex
486
+ return to_hex(value).lower()
487
+ except ValueError:
488
+ return None
489
+
490
+
491
+ def _drawables(el):
492
+ """The painted descendants of ``el``: real drawables, never a <defs> template or a <use>."""
493
+ skip = {f"{{{SVG}}}defs", f"{{{SVG}}}clipPath"}
494
+ stack = [el]
495
+ while stack:
496
+ node = stack.pop()
497
+ if node is not el and node.tag in skip:
498
+ continue
499
+ if node is not el and node.tag in _DRAWABLE and node.tag != f"{{{SVG}}}use":
500
+ yield node
501
+ stack.extend(reversed(list(node)))
502
+
503
+
504
+ def _uniform_paint(el, prop):
505
+ """The one paint every drawable under ``el`` uses for ``prop``, or ``None``."""
506
+ seen = set()
507
+ for d in _drawables(el):
508
+ seen.add(_paint(d, prop))
509
+ if len(seen) > 1:
510
+ return None
511
+ seen.discard(None)
512
+ value = seen.pop() if len(seen) == 1 else None
513
+ return None if value == "none" else value
514
+
515
+
516
+ def _token_for(hex_, preferred, by_hex):
517
+ candidates = by_hex.get(hex_)
518
+ if not candidates:
519
+ return None
520
+ return preferred if preferred in candidates else candidates[0]
521
+
522
+
523
+ def _var_paint(el, prop, token, hex_):
524
+ """Rewrite one paint declaration to ``var(--fx-<token>, <hex>)`` (theme_vars=True)."""
525
+ for d in _drawables(el):
526
+ if _paint(d, prop) != hex_:
527
+ continue
528
+ style = _parse_style(d.get("style"))
529
+ style[prop] = f"var(--fx-{token}, {hex_})"
530
+ d.set("style", "; ".join(f"{k}: {v}" for k, v in style.items()))
531
+ d.attrib.pop(prop, None)
532
+
533
+
534
+ def _tag_inks(reg, guides, id_map, tokens, theme_vars) -> None:
535
+ by_hex: dict = {}
536
+ for token, hex_ in tokens.items(): # insertion order = preference order (ink first)
537
+ by_hex.setdefault(hex_.lower(), []).append(token)
538
+ targets = [] # (element, preferred token, explicit inks)
539
+ for g in guides:
540
+ el = id_map.get(g.gid)
541
+ if el is None:
542
+ continue
543
+ if g.role == "background":
544
+ targets.append((el, _BACKGROUND_TOKEN.get(g.text, "plot"), None))
545
+ elif g.role in INK_ROLES:
546
+ targets.append((el, INK_ROLES[g.role], None))
547
+ for m in reg.marks:
548
+ if m.series is None and m.role in INK_ROLES and m.gid in id_map:
549
+ targets.append((id_map[m.gid], INK_ROLES[m.role], m.data.get("ink")))
550
+ label_gid = m.data.get("label_gid")
551
+ if label_gid in id_map:
552
+ targets.append((id_map[label_gid], "ink", m.data.get("ink_label")))
553
+ for el, preferred, explicit in targets:
554
+ for prop in ("fill", "stroke"):
555
+ hex_ = _uniform_paint(el, prop)
556
+ if hex_ is None:
557
+ continue
558
+ token = (explicit or {}).get(prop) if explicit else None
559
+ token = token or _token_for(hex_, preferred, by_hex)
560
+ if token is None:
561
+ continue
562
+ el.set(f"data-ink-{prop}", token)
563
+ if theme_vars:
564
+ _var_paint(el, prop, token, hex_)
565
+
566
+
567
+ _NUM = __import__('re').compile(r'[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?')
568
+
569
+
570
+ def _vectorize_colorbars(root, guides, id_map, warnings) -> None:
571
+ """Replace each colour key's N solid quads with ONE ``<rect>`` filled by a hard-stepped
572
+ ``<linearGradient>`` (two stops per colour) in ``<defs>``: byte-light, exactly the quads'
573
+ colours, and redrawable by a consumer from the manifest's colour scale. The rect is the quads'
574
+ bounding box in SVG user units; the gradient runs along the key's long axis between the first
575
+ and last quad boundary, in user space, so an inverted axis just runs backwards. The solids
576
+ group keeps its id and role."""
577
+ for g in guides:
578
+ grad = g.data.get('_gradient') if g.role == 'colorbar' else None
579
+ if not grad:
580
+ continue
581
+ group = id_map.get(grad['solids'])
582
+ if group is None or group.get('data-rasterized') == '1':
583
+ continue
584
+ paths = group.findall(f'{{{SVG}}}path')
585
+ if len(paths) != len(grad['colors']):
586
+ warnings.append(f"colorbar '{g.gid}': {len(paths)} solid quads vs {len(grad['colors'])} colours; kept as drawn")
587
+ continue
588
+ xs, ys = [], []
589
+ for path in paths:
590
+ nums = [float(t) for t in _NUM.findall(path.get('d', ''))]
591
+ xs.extend(nums[0::2])
592
+ ys.extend(nums[1::2])
593
+ if not xs:
594
+ continue
595
+ x0, x1, y0, y1 = min(xs), max(xs), min(ys), max(ys)
596
+ gid = grad['solids'] + '.gradient'
597
+ defs = root.find(f'{{{SVG}}}defs')
598
+ if defs is None:
599
+ defs = etree.SubElement(root, f'{{{SVG}}}defs')
600
+ root.insert(0, defs)
601
+ lg = etree.SubElement(defs, f'{{{SVG}}}linearGradient', id=gid, gradientUnits='userSpaceOnUse')
602
+ mid_x, mid_y = (x0 + x1) / 2, (y0 + y1) / 2
603
+ if grad['axis'] == 'y':
604
+ lg.set('x1', _fmt(mid_x)); lg.set('x2', _fmt(mid_x))
605
+ lg.set('y1', _fmt(grad['start'])); lg.set('y2', _fmt(grad['end']))
606
+ else:
607
+ lg.set('y1', _fmt(mid_y)); lg.set('y2', _fmt(mid_y))
608
+ lg.set('x1', _fmt(grad['start'])); lg.set('x2', _fmt(grad['end']))
609
+ offsets = grad['offsets']
610
+ for k, colour in enumerate(grad['colors']):
611
+ for offset in (offsets[k], offsets[k + 1]):
612
+ offset = min(max(float(offset), 0.0), 1.0) + 0.0 # clamp; "+ 0.0" turns -0.0 into 0.0
613
+ stop = etree.SubElement(lg, f'{{{SVG}}}stop', offset=_fmt(offset))
614
+ stop.set('stop-color', colour)
615
+ if grad['opacities'][k] < 1:
616
+ stop.set('stop-opacity', _fmt(grad['opacities'][k]))
617
+ rect = etree.Element(f'{{{SVG}}}rect', x=_fmt(x0), y=_fmt(y0), width=_fmt(x1 - x0), height=_fmt(y1 - y0))
618
+ clip = paths[0].get('clip-path')
619
+ if clip:
620
+ rect.set('clip-path', clip)
621
+ rect.set('style', f'fill: url(#{gid}); stroke: none')
622
+ for path in paths:
623
+ group.remove(path)
624
+ group.append(rect)
625
+
626
+
627
+ def _group_legacy_contour(mark, id_map):
628
+ nodes = [id_map[g] for g in mark.member_gids if g in id_map]
629
+ if not nodes: return
630
+ parent = nodes[0].getparent()
631
+ positions = [parent.index(n) for n in nodes if n.getparent() is parent]
632
+ if len(positions) != len(nodes) or positions != list(range(positions[0], positions[0] + len(nodes))):
633
+ return # Keep truthful per-level references if callers interleaved artists.
634
+ group = etree.Element(f'{{{SVG}}}g', id=mark.gid)
635
+ parent.insert(positions[0], group)
636
+ for node in nodes: group.append(node)
637
+ id_map[mark.gid] = group
638
+ mark.data['field_members'] = [node.get('id') for node in nodes]