fluxplot 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. fluxplot/__init__.py +115 -0
  2. fluxplot/_fieldmap.py +97 -0
  3. fluxplot/_mesh_reduce.py +54 -0
  4. fluxplot/_scene3d_size.py +95 -0
  5. fluxplot/_viewer/THIRD-PARTY.txt +23 -0
  6. fluxplot/_viewer/flux-model3d-viewer.min.js +4221 -0
  7. fluxplot/_viewer/stamp.json +4 -0
  8. fluxplot/api.py +1196 -0
  9. fluxplot/autotag.py +164 -0
  10. fluxplot/base.mplstyle +0 -0
  11. fluxplot/brackets.py +242 -0
  12. fluxplot/canonical_json.py +23 -0
  13. fluxplot/capture.py +150 -0
  14. fluxplot/colorcheck.py +285 -0
  15. fluxplot/colors.py +727 -0
  16. fluxplot/colorscale.py +477 -0
  17. fluxplot/data.py +178 -0
  18. fluxplot/definitions/colormaps.json +1639 -0
  19. fluxplot/definitions/flexoki.tokens.json +2571 -0
  20. fluxplot/definitions/palettes.json +2547 -0
  21. fluxplot/descriptors.py +87 -0
  22. fluxplot/fields.py +611 -0
  23. fluxplot/fits.py +240 -0
  24. fluxplot/glb.py +84 -0
  25. fluxplot/ids.py +173 -0
  26. fluxplot/images.py +362 -0
  27. fluxplot/integrity.py +27 -0
  28. fluxplot/manifest.py +788 -0
  29. fluxplot/mesh3d.py +376 -0
  30. fluxplot/panels.py +284 -0
  31. fluxplot/postprocess.py +638 -0
  32. fluxplot/presets.py +66 -0
  33. fluxplot/provenance.py +177 -0
  34. fluxplot/raster.py +295 -0
  35. fluxplot/recipe.py +178 -0
  36. fluxplot/render.py +66 -0
  37. fluxplot/roles.py +147 -0
  38. fluxplot/scene3d.py +386 -0
  39. fluxplot/scene3d_manifest.py +112 -0
  40. fluxplot/scene3d_viewer.py +633 -0
  41. fluxplot/schemas/.gitkeep +0 -0
  42. fluxplot/schemas/manifest.schema.json +2479 -0
  43. fluxplot/schemas/recipe.schema.json +179 -0
  44. fluxplot/schemas/scene3d.schema.json +461 -0
  45. fluxplot/seaborn_adapters.py +323 -0
  46. fluxplot/signature_fluxplots/__init__.py +18 -0
  47. fluxplot/signature_fluxplots/_colour.py +412 -0
  48. fluxplot/signature_fluxplots/fluxbox.py +433 -0
  49. fluxplot/signature_fluxplots/glowbar.py +769 -0
  50. fluxplot/signature_fluxplots/hexmatrix.py +927 -0
  51. fluxplot/stats/__init__.py +63 -0
  52. fluxplot/stats/_common.py +196 -0
  53. fluxplot/stats/multi_group.py +443 -0
  54. fluxplot/stats/paired.py +209 -0
  55. fluxplot/stats/two_group.py +149 -0
  56. fluxplot/style.py +469 -0
  57. fluxplot/surface.py +487 -0
  58. fluxplot/surface3d.py +197 -0
  59. fluxplot/tagger.py +561 -0
  60. fluxplot/version.py +19 -0
  61. fluxplot-0.1.0.dist-info/METADATA +1199 -0
  62. fluxplot-0.1.0.dist-info/RECORD +65 -0
  63. fluxplot-0.1.0.dist-info/WHEEL +4 -0
  64. fluxplot-0.1.0.dist-info/licenses/LICENSE +21 -0
  65. fluxplot-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +472 -0
fluxplot/images.py ADDED
@@ -0,0 +1,362 @@
1
+ """``fp.image`` and ``fp.scalebar``: micrographs and other pixel data as first-class, addressable
2
+ plot content.
3
+
4
+ An image is one or more **channels** (a ``(H, W)`` matrix each), every channel with its own LUT
5
+ (a colormap, or a colour name meaning a black-to-colour ramp) and **display range** (the black and
6
+ white points; default the 1st–99.8th percentiles). fluxplot composites the channels into one RGB
7
+ ``imshow`` in Python and records everything a consumer needs to redo it: each channel is a colour
8
+ scale in the manifest (``<series>.<channel>``, linear norm over the display range,
9
+ ``recolor: "regenerate"`` — or ``"raster"`` with ``value_raster=True``, when the channel's values
10
+ travel beside the SVG) and a recipe control, so Flux's colour-scale editor edits a channel's LUT or
11
+ brightness/contrast and reruns. ``pixel_size`` puts the axes in physical units, and
12
+ :func:`scalebar` draws a vector bar of a stated length in those units.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from typing import Any, Dict, List, Optional, Tuple
18
+
19
+ import numpy as np
20
+
21
+ from . import colorscale as _colorscale
22
+ from . import data as _data
23
+ from . import tagger as _tagger
24
+ from .descriptors import Mark
25
+
26
+ __all__ = ["image", "scalebar", "ImageResult", "DEFAULT_CHANNEL_COLOURS", "DEFAULT_DISPLAY_PERCENTILES"]
27
+
28
+ #: the LUT colours of a multi-channel image without ``luts``: the fluorescence-microscopy
29
+ #: convention (green / magenta first, since they stay distinct for every common colour deficiency)
30
+ DEFAULT_CHANNEL_COLOURS = ("#00ff00", "#ff00ff", "#00ffff", "#ffff00", "#ff0000", "#0000ff")
31
+ #: the default display range: the (black, white) percentiles of a channel's finite values
32
+ DEFAULT_DISPLAY_PERCENTILES = (1.0, 99.8)
33
+ COMPOSITES = ("add", "max")
34
+ _MONO_PREFIX = "image.mono:"
35
+
36
+
37
+ # ---------------------------------------------------------------------------------------------
38
+ # LUTs
39
+ # ---------------------------------------------------------------------------------------------
40
+ def resolve_lut(spec):
41
+ """A channel LUT: a Colormap as is; a registered colormap name (matplotlib's or fluxplot's);
42
+ else a colour (``"green"``, ``"#00ff00"``) meaning the black-to-colour ramp
43
+ ``image.mono:<hex>``."""
44
+ from matplotlib import colors as mcolors
45
+ from ._fieldmap import resolve_colormap
46
+ if spec is None:
47
+ return resolve_colormap("gray")
48
+ if isinstance(spec, mcolors.Colormap):
49
+ return spec
50
+ name = str(spec)
51
+ if name.startswith(_MONO_PREFIX):
52
+ return _mono(name[len(_MONO_PREFIX):])
53
+ try:
54
+ return resolve_colormap(name)
55
+ except (KeyError, ValueError):
56
+ pass
57
+ if not mcolors.is_color_like(name):
58
+ raise ValueError(f"image: LUT {spec!r} is neither a colormap name nor a colour")
59
+ return _mono(name)
60
+
61
+
62
+ def _mono(colour):
63
+ from matplotlib import colors as mcolors
64
+ hex_ = mcolors.to_hex(colour, keep_alpha=False).lower()
65
+ cm = mcolors.LinearSegmentedColormap.from_list(_MONO_PREFIX + hex_, ["#000000", hex_], N=256)
66
+ cm.set_bad((0.0, 0.0, 0.0, 0.0))
67
+ return cm
68
+
69
+
70
+ # ---------------------------------------------------------------------------------------------
71
+ # the image
72
+ # ---------------------------------------------------------------------------------------------
73
+ @dataclass
74
+ class ImageResult:
75
+ """What :func:`image` drew."""
76
+
77
+ ax: Any
78
+ #: the composited RGB ``AxesImage``
79
+ artist: Any
80
+ #: channel names in stack order (``None`` names → ``ch0, ch1, …``; a 2-D image has one channel)
81
+ channels: List[str]
82
+ #: channel name → the values drawn (``float``, NaN for missing), ``(H, W)`` each
83
+ data: Dict[str, np.ndarray]
84
+ #: channel name → its LUT (a Colormap)
85
+ luts: Dict[str, Any]
86
+ #: channel name → ``(black, white)`` display range actually used
87
+ display_range: Dict[str, Tuple[float, float]]
88
+ #: channel name → the recipe control key / manifest colour-scale id
89
+ keys: Dict[str, str]
90
+ #: ``(row, column)`` pixel size in ``units``, or ``None`` (pixel coordinates)
91
+ pixel_size: Optional[Tuple[float, float]]
92
+ units: str
93
+ #: the ``imshow`` extent ``[left, right, bottom, top]``
94
+ extent: List[float]
95
+ #: the composited RGB array, ``(H, W, 3)`` in ``[0, 1]``
96
+ rgb: np.ndarray = field(repr=False, default=None)
97
+ #: channel name → a ``ScalarMappable`` (LUT + display range) for ``fp.colorbar``
98
+ mappables: Dict[str, Any] = field(default_factory=dict, repr=False)
99
+
100
+ @property
101
+ def shape(self) -> Tuple[int, int]:
102
+ return self.rgb.shape[:2]
103
+
104
+
105
+ def _stack(data, channels):
106
+ """``(values (C, H, W) float, names)`` from a 2-D image or a (C, H, W) / (H, W, C) stack."""
107
+ arr = np.ma.filled(np.ma.asarray(data, dtype=float), np.nan)
108
+ if arr.ndim == 2:
109
+ if channels is not None and len(channels) != 1:
110
+ raise ValueError(f"image: a 2-D image has one channel, but channels= names {len(channels)}")
111
+ return arr[None], (None if channels is None else [str(channels[0])])
112
+ if arr.ndim != 3:
113
+ raise ValueError(f"image: data must be (H, W), (C, H, W) or (H, W, C); got shape {arr.shape}")
114
+ if channels is not None:
115
+ n = len(channels)
116
+ if arr.shape[0] == n and arr.shape[-1] != n:
117
+ stack = arr
118
+ elif arr.shape[-1] == n:
119
+ stack = np.moveaxis(arr, -1, 0)
120
+ elif arr.shape[0] == n:
121
+ stack = arr
122
+ else:
123
+ raise ValueError(f"image: channels= names {n} channels but data has shape {arr.shape}")
124
+ return stack, [str(c) for c in channels]
125
+ if arr.shape[0] <= 4 < arr.shape[-1]:
126
+ stack = arr
127
+ elif arr.shape[-1] <= 4:
128
+ stack = np.moveaxis(arr, -1, 0)
129
+ else:
130
+ raise ValueError(f"image: cannot tell the channel axis of shape {arr.shape}; pass channels=[...]")
131
+ return stack, [f"ch{i}" for i in range(stack.shape[0])]
132
+
133
+
134
+ def _per_channel(value, n, what):
135
+ """Broadcast a per-channel option: ``None`` → ``[None] * n``; one value → repeated; a list of
136
+ ``n`` → as is."""
137
+ if value is None:
138
+ return [None] * n
139
+ if isinstance(value, (list, tuple)) and len(value) == n and (n != 2 or what != "display_range"
140
+ or any(isinstance(v, (list, tuple)) or v is None for v in value)):
141
+ return list(value)
142
+ if what == "display_range":
143
+ if isinstance(value, (list, tuple)) and len(value) == 2 and all(isinstance(v, (int, float, np.number)) for v in value):
144
+ return [tuple(value)] * n
145
+ raise ValueError(f"image: display_range must be (lo, hi) or one pair per channel ({n})")
146
+ if isinstance(value, (list, tuple)):
147
+ if len(value) != n:
148
+ raise ValueError(f"image: {what} has {len(value)} entries for {n} channels")
149
+ return list(value)
150
+ return [value] * n
151
+
152
+
153
+ def _auto_range(values):
154
+ finite = values[np.isfinite(values)]
155
+ if finite.size == 0:
156
+ return 0.0, 1.0
157
+ lo, hi = (float(q) for q in np.percentile(finite, DEFAULT_DISPLAY_PERCENTILES))
158
+ if lo == hi:
159
+ lo, hi = float(finite.min()), float(finite.max())
160
+ if lo == hi:
161
+ hi = lo + 1.0
162
+ return lo, hi
163
+
164
+
165
+ def image(ax, data, *, series, pixel_size=None, units="µm", origin="upper", channels=None, luts=None,
166
+ display_range=None, composite="add", value_raster=False, key=None, **imshow_kw) -> ImageResult:
167
+ """Draw a single- or multi-channel image with recorded LUTs and display ranges.
168
+
169
+ Parameters
170
+ ----------
171
+ ax, data
172
+ The axes and the pixels: ``(H, W)``, or a ``(C, H, W)`` / ``(H, W, C)`` stack (the channel
173
+ axis is the one matching ``len(channels)``, else the one of length ≤ 4). Missing pixels
174
+ (NaN / masked) are transparent.
175
+ series
176
+ The series name: the image is ``<series>.image`` and every channel's colour scale and
177
+ recipe control is ``<series>.<channel>`` (a 2-D image with no ``channels``: ``<series>``).
178
+ pixel_size, units
179
+ The physical size of a pixel (one number, or ``(row, column)``) and its unit; the axes are
180
+ then in those units (``extent``), and :func:`scalebar` lengths are too.
181
+ origin
182
+ ``"upper"`` (row 0 at the top, the imaging convention) or ``"lower"``.
183
+ channels
184
+ Channel names for a stack (default ``ch0, ch1, …``).
185
+ luts
186
+ Per-channel LUT (one for all, or a list): a colormap name / Colormap, or a colour name
187
+ meaning a black-to-colour ramp. Default ``gray`` for one channel, else green, magenta,
188
+ cyan, yellow, red, blue.
189
+ display_range
190
+ Per-channel ``(black, white)`` (one pair for all, or a list): the linear norm's limits.
191
+ Default the 1st–99.8th percentiles of each channel's finite values.
192
+ composite
193
+ How channels combine: ``"add"`` (sum, clipped) or ``"max"``.
194
+ value_raster
195
+ Write each channel's values as ``<plot>.<key>.values.json`` beside the SVG and mark its
196
+ scale ``recolor: "raster"``, so a consumer can repaint from the values.
197
+ key
198
+ Override the control-key root (default ``series``).
199
+ **imshow_kw
200
+ Passed to ``Axes.imshow`` (``interpolation`` defaults to ``"nearest"``).
201
+
202
+ Returns
203
+ -------
204
+ ImageResult
205
+ """
206
+ from matplotlib.cm import ScalarMappable
207
+ from matplotlib import colors as mcolors
208
+ from .fields import _options
209
+ if composite not in COMPOSITES:
210
+ raise ValueError(f"image: composite must be one of {COMPOSITES}, got {composite!r}")
211
+ if origin not in ("upper", "lower"):
212
+ raise ValueError(f"image: origin must be 'upper' or 'lower', got {origin!r}")
213
+ stack, names = _stack(data, channels)
214
+ n, h, w = stack.shape
215
+ single_unnamed = names is None
216
+ if single_unnamed:
217
+ names = ["ch0"]
218
+ if len(set(names)) != n:
219
+ raise ValueError(f"image: channel names must be distinct, got {names}")
220
+ lut_list = _per_channel(luts, n, "luts")
221
+ ranges = _per_channel(display_range, n, "display_range")
222
+ root = str(key) if key is not None else str(series)
223
+ reg = _tagger.registry_for(ax.figure)
224
+
225
+ rgb = np.zeros((h, w, 3))
226
+ result_luts, result_ranges, keys, mappables, channel_records, scale_records, controls = {}, {}, {}, {}, [], [], {}
227
+ for i, name in enumerate(names):
228
+ vals = stack[i]
229
+ lut = lut_list[i]
230
+ if lut is None:
231
+ lut = "gray" if n == 1 else DEFAULT_CHANNEL_COLOURS[i % len(DEFAULT_CHANNEL_COLOURS)]
232
+ lo, hi = ranges[i] if ranges[i] is not None else _auto_range(vals)
233
+ ckey = root if single_unnamed else f"{root}.{name}"
234
+ opts = {"cmap": lut if isinstance(lut, mcolors.Colormap) else str(lut), "vmin": float(lo), "vmax": float(hi)}
235
+ # the recipe's colour controls for this channel (a LUT or display range edited in Flux)
236
+ ckey = _options(ax, series, ckey, opts, resolve=resolve_lut)
237
+ cmap = resolve_lut(opts["cmap"])
238
+ norm = opts.get("norm")
239
+ if norm is None:
240
+ norm = mcolors.Normalize(vmin=opts.get("vmin", lo), vmax=opts.get("vmax", hi))
241
+ if norm.vmin is None or norm.vmax is None:
242
+ norm.vmin, norm.vmax = (lo if norm.vmin is None else norm.vmin), (hi if norm.vmax is None else norm.vmax)
243
+ sm = ScalarMappable(norm=norm, cmap=cmap)
244
+ rgba = sm.to_rgba(np.ma.masked_invalid(vals), bytes=False)
245
+ layer = rgba[..., :3] * rgba[..., 3:4] # a transparent (missing) pixel adds nothing
246
+ rgb = np.maximum(rgb, layer) if composite == "max" else rgb + layer
247
+ result_luts[name], result_ranges[name], keys[name], mappables[name] = cmap, (float(norm.vmin), float(norm.vmax)), ckey, sm
248
+ field_like = {"normalization": {"kind": type(norm).__name__, "vmin": float(norm.vmin), "vmax": float(norm.vmax),
249
+ "clip": bool(norm.clip)}, "cmap": cmap.name,
250
+ "cmapSpec": _colorscale.cmap_spec(cmap, resolve_lut)}
251
+ controls[ckey] = _colorscale.controls_state(field_like)
252
+ record = _colorscale.scale_record(ckey, sm, label=name if not single_unnamed else None,
253
+ extend="neither", recolor="raster" if value_raster else "regenerate")
254
+ scale_records.append(record)
255
+ channel_records.append({"name": name, "lut": controls[ckey]["cmap"], "displayRange": [float(norm.vmin), float(norm.vmax)],
256
+ "scale": ckey, "controlKey": ckey})
257
+ rgb = np.clip(rgb, 0.0, 1.0)
258
+
259
+ if pixel_size is not None:
260
+ py, px = (float(pixel_size), float(pixel_size)) if np.ndim(pixel_size) == 0 else (float(pixel_size[0]), float(pixel_size[1]))
261
+ if py <= 0 or px <= 0:
262
+ raise ValueError("image: pixel_size must be positive")
263
+ extent = [0.0, w * px, h * py, 0.0] if origin == "upper" else [0.0, w * px, 0.0, h * py]
264
+ size = (py, px)
265
+ else:
266
+ extent = [-0.5, w - 0.5, h - 0.5, -0.5] if origin == "upper" else [-0.5, w - 0.5, -0.5, h - 0.5]
267
+ size = None
268
+ imshow_kw.setdefault("interpolation", "nearest")
269
+ artist = ax.imshow(rgb, origin=origin, extent=extent, **imshow_kw)
270
+
271
+ payload = {"shape": [int(h), int(w)], "channels": channel_records, "composite": composite, "origin": origin,
272
+ "extent": [float(v) for v in extent], "units": str(units),
273
+ "pixelSize": None if size is None else [size[0], size[1]]}
274
+ mark_data = {"image": payload, "color_scales": scale_records, "color_controls": controls, "color_paint": "fill"}
275
+ if value_raster:
276
+ mark_data["value_rasters"] = [
277
+ {"key": keys[name], "payload": {"spec": "fluxplot/values", "scale": keys[name], "shape": [int(h), int(w)],
278
+ "values": _data.values(stack[i].reshape(-1))}}
279
+ for i, name in enumerate(names)]
280
+ reg.add(Mark(role="image", series=series, kind="image", x=None, y=None, artists=[artist], axes=ax, data=mark_data))
281
+ return ImageResult(ax=ax, artist=artist, channels=list(names), data={nm: stack[i] for i, nm in enumerate(names)},
282
+ luts=result_luts, display_range=result_ranges, keys=keys, pixel_size=size, units=str(units),
283
+ extent=[float(v) for v in extent], rgb=rgb, mappables=mappables)
284
+
285
+
286
+ # ---------------------------------------------------------------------------------------------
287
+ # the scale bar
288
+ # ---------------------------------------------------------------------------------------------
289
+ _LOCS = {
290
+ "lower right": (1.0, 0.0), "lower left": (0.0, 0.0), "upper right": (1.0, 1.0), "upper left": (0.0, 1.0),
291
+ "lower center": (0.5, 0.0), "upper center": (0.5, 1.0),
292
+ }
293
+
294
+
295
+ def scalebar(ax, length, units="µm", *, loc="lower right", label=None, color=None, thickness=2.0,
296
+ pad=0.4, name=None, text_kw=None):
297
+ """A vector scale bar of ``length`` data units, anchored in a corner of the axes.
298
+
299
+ The bar is a ``Line2D`` whose x extent is exactly ``length`` in data coordinates (so it is
300
+ true to :func:`image`'s ``pixel_size``) and whose vertical anchor is an axes fraction, with
301
+ its label (default ``"<length> <units>"``) centred above it (below, for the upper corners).
302
+ ``pad`` is the inset from the axes edge in units of the label's font size. ``color`` paints
303
+ bar and label (default: the theme's ink, so it reads on a dark ground too). Registers
304
+ ``Mark(role="scalebar")`` with ``{length, units}``; the label is ``<id>.label``.
305
+ """
306
+ import matplotlib
307
+ from matplotlib import transforms as mtransforms
308
+ from matplotlib.lines import Line2D
309
+ if length <= 0:
310
+ raise ValueError("scalebar: length must be positive")
311
+ if loc not in _LOCS:
312
+ raise ValueError(f"scalebar: loc must be one of {sorted(_LOCS)}, got {loc!r}")
313
+ reg = _tagger.registry_for(ax.figure)
314
+ idx = reg.next_overlay_index("scalebar")
315
+ if name is None:
316
+ name = str(idx)
317
+ text_kw = dict(text_kw or {})
318
+ fontsize = text_kw.get("fontsize", matplotlib.rcParams["font.size"])
319
+ fontsize_pt = matplotlib.font_manager.FontProperties(size=fontsize).get_size_in_points()
320
+ # the axes' size in points, for pad (font sizes) → axes fraction
321
+ bbox = ax.get_position()
322
+ fig_w, fig_h = ax.figure.get_size_inches()
323
+ ax_w_pt, ax_h_pt = max(bbox.width * fig_w * 72, 1e-9), max(bbox.height * fig_h * 72, 1e-9)
324
+ pad_x, pad_y = pad * fontsize_pt / ax_w_pt, pad * fontsize_pt / ax_h_pt
325
+ hx, hy = _LOCS[loc]
326
+ x0, x1 = ax.get_xlim()
327
+ span = x1 - x0
328
+ frac_len = length / abs(span) if span else 0.0
329
+ if hx == 1.0:
330
+ right = 1.0 - pad_x
331
+ left = right - frac_len
332
+ elif hx == 0.0:
333
+ left = pad_x
334
+ right = left + frac_len
335
+ else:
336
+ left, right = 0.5 - frac_len / 2, 0.5 + frac_len / 2
337
+ y = pad_y if hy == 0.0 else 1.0 - pad_y
338
+ # x in data units (the bar's length is the statement), y as an axes fraction (the anchor)
339
+ xa = x0 + left * span
340
+ xb = xa + (length if span >= 0 else -length)
341
+ trans = mtransforms.blended_transform_factory(ax.transData, ax.transAxes)
342
+ themed = color is None
343
+ if themed:
344
+ color = matplotlib.rcParams["text.color"]
345
+ bar = Line2D([xa, xb], [y, y], transform=trans, color=color, linewidth=thickness, solid_capstyle="butt", zorder=10)
346
+ ax.add_line(bar)
347
+ if label is None:
348
+ label = f"{length:g} {units}".rstrip()
349
+ gap = 0.4 * fontsize_pt / ax_h_pt
350
+ if hy == 0.0:
351
+ ty, va = y + gap, "bottom"
352
+ else:
353
+ ty, va = y - gap, "top"
354
+ text_kw.setdefault("fontsize", fontsize)
355
+ txt = ax.text((xa + xb) / 2, ty, label, transform=trans, ha="center", va=va, color=color, zorder=10, **text_kw)
356
+ data = {"length": float(length), "units": str(units), "label": str(label), "label_artist": txt, "index": idx, "loc": loc}
357
+ if themed:
358
+ data["ink"] = {"stroke": "ink"}
359
+ data["ink_label"] = {"fill": "ink"}
360
+ reg.add(Mark(role="scalebar", series=None, name=name, artists=[bar], axes=ax, data=data))
361
+ bar._fluxplot_label = txt
362
+ return bar
fluxplot/integrity.py ADDED
@@ -0,0 +1,27 @@
1
+ """Referential checks against the SVG actually drawn, before any files are replaced."""
2
+ from __future__ import annotations
3
+
4
+
5
+ def validate_references(manifest, present):
6
+ refs = set()
7
+ for axis in manifest['axes']:
8
+ if axis.get('svgId'): refs.add(axis['svgId'])
9
+ for series in manifest['series']:
10
+ for value in series['svg'].values():
11
+ refs.update(value if isinstance(value, list) else [value])
12
+ for part in series.get('components', []):
13
+ refs.add(part['svgId']); refs.update(part.get('members', []))
14
+ for point in series.get('points', []): refs.add(point['svgId'])
15
+ for guide in manifest.get('guides', []):
16
+ if guide.get('svgId'): refs.add(guide['svgId'])
17
+ if guide.get('mappable'): refs.add(guide['mappable'])
18
+ for part in guide.get('parts', []): refs.add(part['svgId'])
19
+ for overlay in manifest.get('overlays', []): refs.add(overlay['svgId'])
20
+ def walk(node):
21
+ if node.get('ref'): refs.add(node['ref'])
22
+ refs.update(node.get('members', []))
23
+ for child in node.get('children', []): walk(child)
24
+ walk(manifest['parts'])
25
+ missing = refs - present
26
+ if missing:
27
+ raise ValueError('manifest references missing SVG parts: ' + ', '.join(sorted(missing)[:10]))