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/presets.py ADDED
@@ -0,0 +1,66 @@
1
+ """Per-role animation preset hints (spec §8).
2
+
3
+ The generator has the most semantic knowledge, so it ships sensible *defaults* — the easy path
4
+ becomes the beautiful path (Flux Slide can produce an elegant build with zero hand-authoring). These
5
+ are hints only; a consumer may override everything.
6
+
7
+ Tier note (see SciForge_Stack_Decision §3.4 / style_principles): straight-edged marks animate via
8
+ compositor-friendly transforms (``grow-from-baseline`` = scaleY); only genuine curves use the
9
+ surgical ``draw-on`` (stroke-dashoffset).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ #: The closed vocabulary of build animations a preset may name. Flux maps each to a player
14
+ #: preset (``grow-from-baseline`` → ``growBaseline``, ``fade-rise`` → ``fadeRise``, …); a name
15
+ #: outside this tuple would silently fall back to a plain fade, so the schema enumerates it.
16
+ PRESET_NAMES = ("draw-on", "fade-in", "stagger-in", "grow-from-baseline", "fade-rise", "write-on", "pop-in")
17
+
18
+ #: What a ``stagger-in`` orders its members by: a data attribute every member carries
19
+ #: (``data-x`` / ``data-y`` / ``data-index`` / ``data-value`` / ``data-count``) or its category.
20
+ STAGGER_BY = ("x", "y", "index", "value", "count", "category")
21
+
22
+ ROLE_PRESETS = {
23
+ "line": {"animation": "draw-on", "durationMs": 800},
24
+ "point": {"animation": "stagger-in", "staggerBy": "index", "staggerMs": 40, "durationMs": 240},
25
+ "bar": {"animation": "grow-from-baseline", "durationMs": 500},
26
+ "area": {"animation": "fade-in", "durationMs": 500},
27
+ "errorbar": {"animation": "fade-in", "durationMs": 300},
28
+ "box": {"animation": "grow-from-baseline", "durationMs": 500},
29
+ # composite statistics fade; filled bodies get no speculative draw-on (plan §4)
30
+ "violin": {"animation": "fade-in", "durationMs": 500},
31
+ "whisker": {"animation": "fade-in", "durationMs": 300},
32
+ "cap": {"animation": "fade-in", "durationMs": 300},
33
+ "median": {"animation": "fade-in", "durationMs": 300},
34
+ "flier": {"animation": "fade-in", "durationMs": 240},
35
+ "mean": {"animation": "fade-in", "durationMs": 300},
36
+ "segment": {"animation": "fade-in", "durationMs": 300},
37
+ "axis": {"animation": "draw-on", "durationMs": 400},
38
+ "gridline": {"animation": "fade-in", "durationMs": 300},
39
+ "legend": {"animation": "fade-rise", "durationMs": 300},
40
+ "colorbar": {"animation": "fade-in", "durationMs": 300},
41
+ "title": {"animation": "fade-in", "durationMs": 300},
42
+ "annotation": {"animation": "fade-rise", "delayMs": 150, "durationMs": 300},
43
+ "reference-line": {"animation": "draw-on", "durationMs": 400},
44
+ "significance-bracket": {"animation": "fade-rise", "delayMs": 200, "durationMs": 300},
45
+ "extra": {"animation": "fade-in", "durationMs": 400},
46
+ # colour-mapped fields: a hexmatrix builds up from its emptiest to its fullest hexagon;
47
+ # meshes, images and contour bands fade as one layer
48
+ "x-hexbin": {"animation": "stagger-in", "staggerBy": "value", "staggerMs": 4, "durationMs": 240},
49
+ "x-hex": {"animation": "stagger-in", "staggerBy": "value", "staggerMs": 4, "durationMs": 240},
50
+ "x-heatmap": {"animation": "fade-in", "durationMs": 500},
51
+ "cell": {"animation": "fade-in", "durationMs": 500},
52
+ "x-contour": {"animation": "draw-on", "durationMs": 600},
53
+ "x-contourf": {"animation": "fade-in", "durationMs": 500},
54
+ "contour-level": {"animation": "fade-in", "durationMs": 500},
55
+ # surface (brain) maps: every region is a filled part
56
+ "surface": {"animation": "fade-in", "durationMs": 500},
57
+ "surface-region": {"animation": "fade-in", "durationMs": 500},
58
+ "scalebar": {"animation": "fade-in", "durationMs": 300},
59
+ }
60
+ assert all(v["animation"] in PRESET_NAMES for v in ROLE_PRESETS.values())
61
+ assert all(v.get("staggerBy", "x") in STAGGER_BY for v in ROLE_PRESETS.values())
62
+
63
+
64
+ def presets_for(roles) -> dict:
65
+ """Return the preset map restricted to the roles actually present in a plot."""
66
+ return {r: ROLE_PRESETS[r] for r in roles if r in ROLE_PRESETS}
fluxplot/provenance.py ADDED
@@ -0,0 +1,177 @@
1
+ """Save-time provenance — automatic, honest discovery of the producing script (plan §1).
2
+
3
+ ``fp.save`` can determine the producing script, interpreter, package versions, source hash and
4
+ Git state without asking the user to repeat facts the runtime already knows. Discovery is
5
+ **deterministic and conservative**: identity comes only from ``__main__.__file__`` (or, when a
6
+ runner such as ``pytest`` is the entry point, the caller's own file), never from notebook history
7
+ or heuristics, and anything ambiguous yields ``None`` (the recipe then says so via
8
+ ``scriptDiscovery: "unavailable"`` rather than guessing).
9
+
10
+ All of this is host-specific, run-varying material — it belongs in the recipe (the provenance
11
+ surface), never in the deterministic SVG/manifest.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import os
17
+ import platform
18
+ import subprocess
19
+ import sys
20
+
21
+ _GIT_TIMEOUT_S = 2.0
22
+
23
+
24
+ def _is_real_script(path) -> bool:
25
+ """True for an existing regular ``.py`` file that plausibly *is* the producing script."""
26
+ if not path or not isinstance(path, str):
27
+ return False
28
+ if path.startswith("<"): # <stdin>, <string>, <ipython-input-...>
29
+ return False
30
+ if not path.endswith(".py"):
31
+ return False
32
+ if not os.path.isfile(path):
33
+ return False
34
+ # ipykernel writes each cell to a real temp file (tmp/ipykernel_<pid>/<hash>.py) for
35
+ # debugger support — a cell fragment is not a rerunnable script. Reject that exact
36
+ # pattern; legitimate scripts in temp directories are unaffected.
37
+ if os.path.basename(os.path.dirname(path)).startswith("ipykernel_"):
38
+ return False
39
+ return True
40
+
41
+
42
+ def _in_installed_packages(path: str) -> bool:
43
+ parts = os.path.abspath(path).split(os.sep)
44
+ return "site-packages" in parts or "dist-packages" in parts
45
+
46
+
47
+ def discover_script() -> str | None:
48
+ """The absolute path of the producing ``.py`` script, or ``None`` when there isn't one.
49
+
50
+ Rules (deterministic, in order):
51
+
52
+ 1. ``__main__.__file__`` if it names an existing regular ``.py`` file that is not part of
53
+ an installed package (``python -m pytest`` must not claim pytest produced the plot).
54
+ 2. When the interpreter was started through an installed entry point — ``python -m pkg``
55
+ (``__main__.__file__`` inside site-packages) or a console script such as ``pytest``
56
+ (``__main__.__file__`` that is not a ``.py`` file) — the first caller frame whose file is
57
+ an existing ``.py`` outside FluxPlot's own code: the user's script, run by a runner.
58
+ 3. Otherwise ``None`` — interactive sessions, notebooks (``__main__`` has no ``__file__``
59
+ at all under ipykernel), ``python -c`` and frozen apps are honestly not rerunnable
60
+ scripts. In particular a notebook cell calling ``mylib.plot()`` must never record
61
+ ``mylib.py`` as the recipe: rerunning it would not reproduce the figure.
62
+ """
63
+ import __main__
64
+
65
+ main_file = getattr(__main__, "__file__", None)
66
+ if _is_real_script(main_file) and not _in_installed_packages(main_file):
67
+ return os.path.abspath(main_file)
68
+ if not isinstance(main_file, str) or not main_file or main_file.startswith("<"):
69
+ return None # no entry-point file: interactive / notebook / -c
70
+
71
+ pkg_dir = os.path.dirname(os.path.abspath(__file__))
72
+ frame = sys._getframe()
73
+ while frame is not None:
74
+ fn = frame.f_code.co_filename
75
+ if fn and not fn.startswith("<"):
76
+ abs_fn = os.path.abspath(fn)
77
+ if not abs_fn.startswith(pkg_dir + os.sep) and _is_real_script(abs_fn) \
78
+ and not _in_installed_packages(abs_fn):
79
+ return abs_fn
80
+ frame = frame.f_back
81
+ return None
82
+
83
+
84
+ #: where a running kernel says which notebook it serves — each set by exactly one host, none guessed
85
+ NOTEBOOK_ENV = "QUARTO_DOCUMENT_PATH"
86
+ NOTEBOOK_GLOBALS = ("__vsc_ipynb_file__", "__session__")
87
+
88
+
89
+ def discover_notebook() -> str | None:
90
+ """The absolute path of the notebook the current kernel serves, or ``None``.
91
+
92
+ Only what the host states outright counts: ``$QUARTO_DOCUMENT_PATH`` (Quarto rendering or
93
+ a live QMD kernel), then the ``__vsc_ipynb_file__`` / ``__session__`` globals VS Code and
94
+ Jupyter put in ``__main__``. Nothing is inferred from the working directory or the stack: a
95
+ wrong notebook would be worse than none.
96
+ """
97
+ import __main__
98
+
99
+ candidates = [os.environ.get(NOTEBOOK_ENV)]
100
+ candidates += [getattr(__main__, name, None) for name in NOTEBOOK_GLOBALS]
101
+ for cand in candidates:
102
+ if isinstance(cand, str) and cand and not cand.startswith("<"):
103
+ if cand.startswith("file://"):
104
+ from urllib.parse import unquote, urlparse
105
+ cand = unquote(urlparse(cand).path)
106
+ if os.path.isfile(cand):
107
+ return os.path.abspath(cand)
108
+ return None
109
+
110
+
111
+ def notebook_block(path: str, cell) -> dict:
112
+ """The recipe's ``notebook`` block: the path, the cell label and the file's SHA-256 (``None``
113
+ when it cannot be read)."""
114
+ out = {"path": str(path), "cell": None if cell is None else str(cell), "sha256": None}
115
+ try:
116
+ with open(path, "rb") as f:
117
+ out["sha256"] = hashlib.sha256(f.read()).hexdigest()
118
+ except OSError:
119
+ pass
120
+ return out
121
+
122
+
123
+ def _git_info(directory: str) -> dict | None:
124
+ """``{"commit": ..., "dirty": ...}`` for the repo containing ``directory``.
125
+
126
+ Fails closed: outside a repository, without git installed, or on any error/timeout this
127
+ returns ``None`` and the caller omits the block — provenance must never break a save.
128
+ """
129
+ def run(*args):
130
+ return subprocess.run(
131
+ ["git", "-C", directory, *args],
132
+ capture_output=True, text=True, timeout=_GIT_TIMEOUT_S,
133
+ )
134
+
135
+ try:
136
+ head = run("rev-parse", "HEAD")
137
+ commit = head.stdout.strip()
138
+ if head.returncode != 0 or not commit:
139
+ return None
140
+ out = {"commit": commit}
141
+ status = run("status", "--porcelain")
142
+ if status.returncode == 0:
143
+ out["dirty"] = bool(status.stdout.strip())
144
+ return out
145
+ except Exception:
146
+ return None
147
+
148
+
149
+ def build_provenance(script_path: str | None, discovery: str) -> dict:
150
+ """The recipe's additive ``provenance`` block.
151
+
152
+ ``discovery`` distinguishes ``automatic`` (we found the script), ``explicit`` (the caller
153
+ recorded it), ``notebook`` (a notebook cell produced it — recorded, not rerunnable) and
154
+ ``unavailable`` (no script — the artifact is not rerunnable). Script-hash failures omit only
155
+ that field; Git is optional and omitted outside a repository. For a notebook, ``script_path``
156
+ is the notebook file: its hash and Git state are recorded the same way.
157
+ """
158
+ import matplotlib
159
+
160
+ from .version import __version__
161
+
162
+ prov = {
163
+ "scriptDiscovery": discovery,
164
+ "python": platform.python_version(),
165
+ "platform": platform.platform(),
166
+ "packages": {"fluxplot": __version__, "matplotlib": matplotlib.__version__},
167
+ }
168
+ if script_path:
169
+ try:
170
+ with open(script_path, "rb") as f:
171
+ prov["scriptSha256"] = hashlib.sha256(f.read()).hexdigest()
172
+ except OSError:
173
+ pass
174
+ git = _git_info(os.path.dirname(os.path.abspath(script_path)) or ".")
175
+ if git is not None:
176
+ prov["git"] = git
177
+ return prov
fluxplot/raster.py ADDED
@@ -0,0 +1,295 @@
1
+ """Automatic rasterization of pathologically heavy artists — the safety default.
2
+
3
+ Why this exists
4
+ ---------------
5
+ A matplotlib artist that draws *N* primitives becomes *N* SVG nodes. At scientific data
6
+ scale that is routinely 10^4–10^5 nodes in a single panel:
7
+
8
+ - a ``LineCollection`` built from per-edge segments (the usual way to draw an SWC
9
+ reconstruction or a graph) emits **one ``<path>`` per segment**;
10
+ - a ``scatter`` emits **one ``<use>`` per point**.
11
+
12
+ Flux inlines every panel's SVG as live DOM, so those nodes are real cost forever after.
13
+ A measured 14-panel figure carrying three neuron reconstructions plus 8.7k-point scatters
14
+ reached 260,907 nodes and ~390 ms per pan frame — roughly 2.5 fps against a 100 ms
15
+ interaction budget — while the *same figure* with its heavy layers rasterized rendered at
16
+ vsync from 5,493 nodes.
17
+
18
+ matplotlib already ships the right tool: ``Artist.set_rasterized(True)`` draws that ONE
19
+ artist through the Agg backend and embeds the result as a single ``<image>``, leaving
20
+ axes, ticks, tick labels, legend, annotations and every other artist as vector. This
21
+ module decides which artists get that treatment, so a plot that would cripple a downstream
22
+ editor is never written in the first place. ``fp.save(..., force_vectors=True)`` opts out.
23
+
24
+ The matplotlib mechanic that makes this non-trivial
25
+ ---------------------------------------------------
26
+ Rasterization **drops the artist's gid**. ``MixedModeRenderer.stop_rasterizing`` draws the
27
+ composited buffer through a *fresh* ``GraphicsContext``, so the ``<image>`` lands carrying
28
+ matplotlib's generated id (``image`` + 10 hex chars) and no wrapping ``<g id="...">``.
29
+ Left alone that would silently delete the part from fluxplot's semantic contract — the
30
+ whole point of the library.
31
+
32
+ Each planned artist receives a temporary draw wrapper that opens an SVG group
33
+ before mixed-mode rendering and flushes the raster inside that group. :func:`reattach`
34
+ then transfers the identity to its actual image. Empty/clipped layers cannot shift
35
+ another layer's identity; multiple images retain a named container. Draw methods,
36
+ raster flags, axes z-order rasterization and figure compositing state are restored.
37
+ The wrapper is local to this figure's export; no Matplotlib class/global is patched.
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import re
42
+ from contextlib import contextmanager
43
+ from dataclasses import dataclass
44
+
45
+ SVG = "http://www.w3.org/2000/svg"
46
+
47
+ #: matplotlib's generated element id (``backend_svg._make_id``): a type prefix + 10 hex chars.
48
+ AUTO_IMAGE_ID = re.compile(r"^image[0-9a-f]{6,}$")
49
+
50
+ #: Per-artist primitive budget above which a layer is rasterized. A Flux figure composes up
51
+ #: to ~14 panels into one inlined SVG, so the per-panel allowance has to leave the *figure*
52
+ #: comfortable. 800 catches the mid-size clouds a 2,000 cutoff left vector (per-class scatters
53
+ #: of 1–2k cells, minor transgenic-line series), pushing a full 14-panel projection figure
54
+ #: toward vsync, while still leaving ordinary plots untouched — a 200-point scatter or a
55
+ #: 500-segment line stays fully vector and per-point addressable.
56
+ DEFAULT_THRESHOLD = 800
57
+
58
+ #: Resolution for the embedded PNGs. Vector content is unaffected (SVG user space is points,
59
+ #: verified in ``NOTES_matplotlib_svg.md`` §6); this only sets how many pixels a rasterized
60
+ #: layer gets. 600 dpi leaves ~2x headroom over a 300 dpi print of a typical 2–3 in panel,
61
+ #: so the layer still looks sharp zoomed in inside a figure editor.
62
+ DEFAULT_DPI = 600
63
+
64
+
65
+ @dataclass
66
+ class RasterItem:
67
+ """One artist selected for rasterization, with the state needed to undo it."""
68
+
69
+ artist: object
70
+ gid: "str | None"
71
+ count: int
72
+ was_rasterized: bool
73
+
74
+ @property
75
+ def label(self) -> str:
76
+ return self.gid or type(self.artist).__name__
77
+
78
+
79
+ def primitive_count(artist) -> int:
80
+ """How many SVG primitives ``artist`` will emit, or 0 when that can't be determined.
81
+
82
+ Counting is deliberately defensive: a save must never fail because a third-party artist
83
+ confused the estimator, so anything unexpected reports 0 (= "not heavy") and is left alone.
84
+ """
85
+ try:
86
+ from matplotlib.artist import Artist
87
+ from matplotlib.collections import Collection, QuadMesh
88
+ from matplotlib.image import _ImageBase
89
+ from matplotlib.lines import Line2D
90
+
91
+ if not isinstance(artist, Artist):
92
+ return 0
93
+ if isinstance(artist, _ImageBase):
94
+ return 0 # already a single <image>
95
+ if isinstance(artist, QuadMesh):
96
+ # get_paths() would materialize every quad just to count them; read the mesh shape.
97
+ coords = getattr(artist, "_coordinates", None)
98
+ if coords is not None and getattr(coords, "ndim", 0) == 3:
99
+ return max(int(coords.shape[0]) - 1, 0) * max(int(coords.shape[1]) - 1, 0)
100
+ return 0
101
+ if isinstance(artist, Collection):
102
+ # scatter: one marker path cycled over N offsets → N <use>.
103
+ # LineCollection: N paths, no meaningful offsets. max() covers both.
104
+ offsets = artist.get_offsets()
105
+ return max(len(artist.get_paths()), 0 if offsets is None else len(offsets))
106
+ if isinstance(artist, Line2D):
107
+ marker = artist.get_marker()
108
+ n = len(artist.get_xdata())
109
+ return (n if marker not in (None, "", " ", "None") else 0) + 1
110
+ return 1
111
+ except Exception:
112
+ return 0
113
+
114
+
115
+ def _draw_order(fig) -> list:
116
+ """Every artist in matplotlib's own draw order.
117
+
118
+ Mirrors ``Figure.draw``/``Axes.draw``: children sorted by zorder with a *stable* sort, so
119
+ ties keep insertion order exactly as matplotlib emits them.
120
+ """
121
+ out: list = []
122
+ seen: set = set()
123
+
124
+ def visit(artist) -> None:
125
+ if id(artist) in seen:
126
+ return
127
+ seen.add(id(artist))
128
+ out.append(artist)
129
+
130
+ def descend(artist):
131
+ if hasattr(artist, 'get_xaxis'):
132
+ for child in sorted(artist.get_children(), key=_zorder):
133
+ descend(child)
134
+ else:
135
+ visit(artist)
136
+ for child in sorted(fig.get_children(), key=_zorder):
137
+ descend(child)
138
+ return out
139
+
140
+
141
+ def _zorder(artist) -> float:
142
+ try:
143
+ return float(artist.get_zorder())
144
+ except Exception:
145
+ return 0.0
146
+
147
+
148
+ def plan(fig, threshold: int = DEFAULT_THRESHOLD, per_artist=None) -> list:
149
+ """Artists heavy enough to rasterize, in the order matplotlib will emit their images.
150
+
151
+ Only *visible* artists count: an invisible one draws nothing, so it emits no image and
152
+ would throw off the document-order match in :func:`reattach`. ``per_artist`` maps
153
+ ``id(artist)`` to a threshold of its own (``Mark.data["raster_threshold"]``): a hexmatrix
154
+ keeps thousands of hexagons addressable where a generic collection would be rasterized.
155
+ """
156
+ per_artist = per_artist or {}
157
+ items = []
158
+ for artist in _draw_order(fig):
159
+ try:
160
+ if not artist.get_visible():
161
+ continue
162
+ except Exception:
163
+ continue
164
+ n = primitive_count(artist)
165
+ # A colour key's solids are drawn as one exact vector gradient by postprocess (A6), never
166
+ # as an image — matplotlib's default rasterization of them is undone around the render.
167
+ cb = getattr(getattr(artist, 'axes', None), '_colorbar', None)
168
+ if cb is not None and artist is cb.solids:
169
+ continue
170
+ # An artist the CALLER already flagged rasterized is planned too, however few primitives it
171
+ # has. matplotlib will emit an <image> for it either way, and reattach keeps each image in its own
172
+ # explicit draw scope, including caller-rasterized lightweight artists. The commonest case is a
173
+ # colorbar: its solids are rasterized by default, are far below any heaviness threshold, and
174
+ # would otherwise silently orphan the plot they belong to.
175
+ ax = getattr(artist, 'axes', None)
176
+ cutoff = ax.get_rasterization_zorder() if ax is not None else None
177
+ inherited = ax is not None and (ax.get_rasterized() or (cutoff is not None and _zorder(artist) < cutoff))
178
+ limit = per_artist.get(id(artist), threshold)
179
+ if n > limit or bool(artist.get_rasterized()) or inherited:
180
+ items.append(
181
+ RasterItem(
182
+ artist=artist,
183
+ gid=artist.get_gid(),
184
+ count=n,
185
+ was_rasterized=bool(artist.get_rasterized()),
186
+ )
187
+ )
188
+ return items
189
+
190
+
191
+ @contextmanager
192
+ def rasterizing(fig, items, force_vectors=False):
193
+ """Render ``fig`` with ``items`` rasterized, then hand the figure back untouched.
194
+
195
+ ``suppressComposite`` is the load-bearing detail. ``Artist.allow_rasterization`` only
196
+ ends a raster run when a NON-rasterized artist is drawn, so *consecutive* rasterized
197
+ artists are flattened into a single ``<image>`` — two heavy layers in one panel (an
198
+ axon and a dendrite, say) would become one element and the second would lose its
199
+ identity entirely. Setting ``suppressComposite`` makes matplotlib stop and restart
200
+ rasterizing around every artist ("restart rasterizing to prevent merging"), which is
201
+ what guarantees the one-image-per-artist mapping :func:`reattach` relies on. Verified:
202
+ 2/3/4 same-zorder rasterized layers emit 1/1/1 images by default and 2/3/4 with it set.
203
+
204
+ It is applied only when something is actually rasterized, so an ordinary save keeps
205
+ matplotlib's default compositing and byte-identical output.
206
+ """
207
+ from .panels import all_axes
208
+ previous = fig.suppressComposite
209
+ axes_state = [(ax, ax.get_rasterization_zorder(), ax.get_rasterized()) for ax in all_axes(fig)]
210
+ draws = []
211
+ fig.suppressComposite = True
212
+ for ax, _, _ in axes_state:
213
+ ax.set_rasterization_zorder(None)
214
+ ax.set_rasterized(False)
215
+ try:
216
+ for it in items:
217
+ it.artist.set_rasterized(not force_vectors)
218
+ if force_vectors or not it.gid: continue
219
+ art = it.artist
220
+ original = art.draw
221
+ had_draw = 'draw' in art.__dict__
222
+ saved_draw = art.__dict__.get('draw')
223
+ draws.append((art, had_draw, saved_draw))
224
+ def draw(renderer, original=original, gid=it.gid):
225
+ # Scope identity around the actual draw, so empty/clipped layers
226
+ # cannot shift another layer's identity. Matplotlib's public
227
+ # renderer group API encloses the mixed-mode image flush.
228
+ renderer.open_group('fluxplot-raster', gid=gid)
229
+ try:
230
+ original(renderer)
231
+ if getattr(renderer, '_rasterizing', False) and renderer._raster_depth == 0:
232
+ renderer.stop_rasterizing()
233
+ renderer._rasterizing = False
234
+ finally:
235
+ renderer.close_group('fluxplot-raster')
236
+ draw._supports_rasterization = True
237
+ art.draw = draw
238
+ yield
239
+ finally:
240
+ fig.suppressComposite = previous
241
+ for art, had_draw, saved_draw in draws:
242
+ if had_draw: art.draw = saved_draw
243
+ else: del art.__dict__['draw']
244
+ for it in items:
245
+ it.artist.set_rasterized(it.was_rasterized)
246
+ for ax, zorder, rasterized in axes_state:
247
+ ax.set_rasterization_zorder(zorder)
248
+ ax.set_rasterized(rasterized)
249
+
250
+
251
+ def reattach(root, items, warnings) -> set:
252
+ """Re-attach each rasterized artist's gid to the ``<image>`` matplotlib emitted for it.
253
+
254
+ Returns the set of gids restored (empty when the match was ambiguous and skipped).
255
+ """
256
+ if not items:
257
+ return set()
258
+ by_id = {el.get('id'): el for el in root.iter() if el.get('id')}
259
+ restored = set()
260
+ for it in items:
261
+ group = by_id.get(it.gid)
262
+ if group is None: continue
263
+ images = list(group.iter(f'{{{SVG}}}image'))
264
+ if len(images) == 1:
265
+ # Preserve the existing contract: the layer ID names the image.
266
+ group.attrib.pop('id', None)
267
+ images[0].set('id', it.gid)
268
+ images[0].set('data-rasterized', '1')
269
+ restored.add(it.gid)
270
+ elif images:
271
+ group.set('data-rasterized', '1')
272
+ for i, image in enumerate(images): image.set('id', f'{it.gid}.raster.{i}')
273
+ restored.add(it.gid)
274
+ elif not len(group):
275
+ group.getparent().remove(group)
276
+ return restored
277
+
278
+
279
+ def describe(items, *, plot_name: str, dpi: int, rasterized: bool) -> str:
280
+ """One human sentence about what happened (or would happen) to the heavy layers."""
281
+ listed = ", ".join(f"{it.label} ({it.count:,} primitives)" for it in items[:4])
282
+ if len(items) > 4:
283
+ listed += f", +{len(items) - 4} more"
284
+ total = sum(it.count for it in items)
285
+ if rasterized:
286
+ return (
287
+ f"fluxplot: '{plot_name}' — rasterized {len(items)} layer(s) at {dpi} dpi: "
288
+ f"{listed}. Axes, ticks, labels and legend stay vector. "
289
+ f"Pass force_vectors=True to keep everything as vectors."
290
+ )
291
+ return (
292
+ f"fluxplot: '{plot_name}' — kept {len(items)} layer(s) as vectors "
293
+ f"(force_vectors=True): {listed}. That is ~{total:,} SVG nodes; editors that inline "
294
+ f"large vector layers can slow inline editors."
295
+ )