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/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))}
|