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