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/autotag.py ADDED
@@ -0,0 +1,164 @@
1
+ """Save-time promotion of labeled ordinary matplotlib artists (plan §3).
2
+
3
+ A conventional labeled plot — ``ax.plot(t, y, label="Control")`` plus only ``fp.save`` — should
4
+ yield a named series with exact data, not an anonymous ``extra.*``. This module promotes raw
5
+ artists to series marks under strict rules:
6
+
7
+ - **Identity comes only from a public artist label** the user authored (non-empty, not
8
+ ``_``-private). Never from color, draw order, geometry shape, linestyle or legend position.
9
+ - **Data comes only from the artist's own exact public state** (``get_data()``,
10
+ ``get_offsets()``, bar patch geometry). Nothing is reconstructed or guessed.
11
+ - **Ambiguity declines**: duplicate labels, or a label colliding with an explicitly tagged
12
+ series, leave every candidate in the honest ``extra.*`` fallback with one actionable warning.
13
+ - **Explicit helpers/tags always win** — an artist already in the registry is never touched.
14
+
15
+ Runs inside :func:`fluxplot.save` before gid assignment; the orphan sweep in ``tagger.py``
16
+ still rescues whatever this module (deliberately) does not claim.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import warnings as _warnings
21
+
22
+ import numpy as np
23
+
24
+ from . import ids as _ids
25
+ from .descriptors import Mark
26
+
27
+ #: Additive manifest provenance for auto-promoted series: how identity and data were captured.
28
+ AUTO_CAPTURE = {"identity": "artist-label", "data": "artist"}
29
+
30
+
31
+ def is_colorbar_axes(ax) -> bool:
32
+ """True if this Axes is a colorbar (added by fig.colorbar), not a plot area."""
33
+ return getattr(ax, "_colorbar", None) is not None or ax.get_label() == "<colorbar>"
34
+
35
+
36
+ def _public_label(artist) -> str | None:
37
+ """The artist's label iff it is public identity (non-empty, not ``_child0``/``_nolegend_``)."""
38
+ lab = artist.get_label()
39
+ if not isinstance(lab, str):
40
+ return None
41
+ if not lab.strip() or lab.startswith("_"):
42
+ return None
43
+ return lab
44
+
45
+
46
+ def _finite_offsets(coll):
47
+ """Exact Nx2 finite offsets, or None (masked/non-finite would shift per-point indices)."""
48
+ off = coll.get_offsets()
49
+ if np.ma.isMaskedArray(off) and np.ma.is_masked(off):
50
+ return None
51
+ arr = np.asarray(off, dtype=float)
52
+ if arr.ndim != 2 or arr.shape[-1] != 2 or arr.size == 0 or not np.isfinite(arr).all():
53
+ return None
54
+ return arr
55
+
56
+
57
+ def extract_xy(artist):
58
+ from .data import artist_xy
59
+ return artist_xy(artist)
60
+
61
+
62
+ def promote_labeled(fig, reg) -> list[str]:
63
+ """Promote safe labeled raw artists on ``fig`` into ``reg``. Returns warning strings."""
64
+ from matplotlib.collections import PathCollection, PolyCollection
65
+ from matplotlib.container import BarContainer
66
+ from matplotlib.lines import Line2D
67
+
68
+ notes: list[str] = []
69
+ already = {id(a) for m in reg.marks for a in m.artists}
70
+
71
+ # candidates in deterministic order: per axes → lines, collections, bar containers
72
+ candidates: list[tuple[str, str, object]] = [] # (label, adapter, artist/container)
73
+ for ax in fig.axes:
74
+ if is_colorbar_axes(ax):
75
+ continue
76
+ for ln in ax.lines:
77
+ if id(ln) in already or not isinstance(ln, Line2D):
78
+ continue
79
+ lab = _public_label(ln)
80
+ if lab is not None:
81
+ candidates.append((lab, "line", ln))
82
+ for coll in ax.collections:
83
+ if id(coll) in already:
84
+ continue
85
+ lab = _public_label(coll)
86
+ if lab is None:
87
+ continue
88
+ if isinstance(coll, PolyCollection):
89
+ candidates.append((lab, "area", coll))
90
+ elif isinstance(coll, PathCollection):
91
+ candidates.append((lab, "point", coll))
92
+ for cont in getattr(ax, "containers", []):
93
+ if not isinstance(cont, BarContainer) or not cont.patches:
94
+ continue
95
+ if any(id(p) in already for p in cont.patches):
96
+ continue
97
+ lab = _public_label(cont)
98
+ if lab is not None:
99
+ candidates.append((lab, "bar", cont))
100
+
101
+ # ambiguity: duplicate candidate labels, or a label whose slug collides with an
102
+ # explicitly tagged series → decline ALL involved candidates (extra.* keeps them honest)
103
+ explicit_slugs = {_ids.series_root(m.series) for m in reg.marks if m.series is not None}
104
+ slug_counts: dict[str, int] = {}
105
+ for lab, _, _ in candidates:
106
+ slug = _ids.series_root(lab)
107
+ slug_counts[slug] = slug_counts.get(slug, 0) + 1
108
+ dropped = sorted(
109
+ {
110
+ lab
111
+ for lab, _, _ in candidates
112
+ if slug_counts[_ids.series_root(lab)] > 1 or _ids.series_root(lab) in explicit_slugs
113
+ }
114
+ )
115
+ if dropped:
116
+ msg = (
117
+ f"fluxplot: not auto-promoting artist label(s) {dropped}: each duplicates another "
118
+ "artist's label or an explicitly tagged series, so identity is ambiguous. The "
119
+ "artists stay addressable as extra.*; give them distinct labels, or use the fp.* "
120
+ "helpers / fp.tag(series=...) for explicit identity."
121
+ )
122
+ _warnings.warn(msg, UserWarning, stacklevel=3)
123
+ notes.append(msg)
124
+ skip = {_ids.series_root(lab) for lab in dropped}
125
+
126
+ for lab, adapter, art in candidates:
127
+ if _ids.series_root(lab) in skip:
128
+ continue
129
+ capture = {"capture": dict(AUTO_CAPTURE)}
130
+ if adapter == "line":
131
+ fx, fy = extract_xy(art)
132
+ reg.add(
133
+ Mark(role="line", series=lab, kind="line", x=fx, y=fy, label=lab,
134
+ artists=[art], live_data=True, data=capture)
135
+ )
136
+ elif adapter == "point":
137
+ arr = _finite_offsets(art)
138
+ if arr is None:
139
+ msg = (
140
+ f"fluxplot: not auto-promoting scatter {lab!r}: offsets are masked or "
141
+ "non-finite, so per-point indices would not match the drawn points. It "
142
+ "stays addressable as extra.*; use fp.scatter for explicit tagging."
143
+ )
144
+ _warnings.warn(msg, UserWarning, stacklevel=3)
145
+ notes.append(msg)
146
+ continue
147
+ reg.add(
148
+ Mark(role="point", series=lab, kind="scatter",
149
+ x=[float(v) for v in arr[:, 0]], y=[float(v) for v in arr[:, 1]],
150
+ label=lab, artists=[art], indexed=True, live_data=True, data=capture)
151
+ )
152
+ elif adapter == "area":
153
+ # exact fill geometry lives in the SVG; the original y1/y2 vectors are not
154
+ # recoverable from the artist, and we do not invent them
155
+ reg.add(Mark(role="area", series=lab, kind="area", label=lab, artists=[art], live_data=True, data=capture))
156
+ elif adapter == "bar":
157
+ from .data import bar_data
158
+ cx, cy, bar = bar_data(art.patches, getattr(art, "orientation", "vertical"))
159
+ capture["bar"] = bar
160
+ reg.add(
161
+ Mark(role="bar", series=lab, kind="bar", x=cx, y=cy, label=lab,
162
+ artists=list(art.patches), indexed=True, live_data=True, data=capture)
163
+ )
164
+ return notes
fluxplot/base.mplstyle ADDED
File without changes
fluxplot/brackets.py ADDED
@@ -0,0 +1,242 @@
1
+ """``fp.brackets``: significance brackets drawn from ``fp.stats`` rows, auto-stacked, with the test
2
+ behind every star recorded in the manifest.
3
+
4
+ >>> rows = fp.stats.pairwise(fp.stats.welch_hedges, {"ctl": ctl, "drug": drug, "sham": sham})
5
+ >>> gb = fp.glowbar(data=df, x="group", y="value", ax=ax)
6
+ >>> gb.brackets(rows) # or fp.brackets(ax, rows, positions=gb.positions)
7
+
8
+ Each post-hoc row names its pair in ``groups``; ``positions`` maps a group name to its x. Brackets
9
+ are placed shortest first, each one step above the data it spans and above every bracket it
10
+ overlaps in x, so no bracket ever crosses another or sits on the points. The manifest overlay of
11
+ each bracket carries ``stats`` — test, statistic, raw and corrected p, correction, effect size and
12
+ its CI, sizes — so the figure states exactly which test each star came from.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple, Union
18
+
19
+ import numpy as np
20
+
21
+ __all__ = ["brackets", "stars", "format_p", "THRESHOLDS"]
22
+
23
+ #: the default star thresholds: ``p < 0.001`` → ``***``, ``p < 0.01`` → ``**``, ``p < 0.05`` → ``*``
24
+ THRESHOLDS = ((0.001, "***"), (0.01, "**"), (0.05, "*"))
25
+
26
+ _CORRECTION_OF = {"p_corrected_holm": "holm", "p_corrected_bh": "bh", "p-value": "none"}
27
+
28
+
29
+ def stars(p: float, thresholds: Sequence[Tuple[float, str]] = THRESHOLDS, ns: str = "ns") -> str:
30
+ """The star label of a p-value: the first threshold it falls under, else ``ns``."""
31
+ if p is None or not math.isfinite(p):
32
+ return ns
33
+ for cut, label in sorted(thresholds, key=lambda t: t[0]):
34
+ if p < cut:
35
+ return label
36
+ return ns
37
+
38
+
39
+ def format_p(p: float, floor: float = 0.001) -> str:
40
+ """``p < 0.001`` below ``floor``, else ``p = 0.003`` (three decimals)."""
41
+ if p is None or not math.isfinite(p):
42
+ return "p = n/a"
43
+ if p < floor:
44
+ return f"p < {floor:g}"
45
+ return f"p = {p:.3f}"
46
+
47
+
48
+ def _positions_from_ticks(ax) -> Dict[str, float]:
49
+ return {lbl.get_text(): float(tick) for tick, lbl in zip(ax.get_xticks(), ax.get_xticklabels())
50
+ if lbl.get_text()}
51
+
52
+
53
+ def _data_top(ax, x_lo: float, x_hi: float) -> Optional[float]:
54
+ """The highest data y drawn between ``x_lo`` and ``x_hi`` (inclusive) on ``ax``, from lines,
55
+ collections (offsets or path vertices) and patches — the things a bracket must clear."""
56
+ from matplotlib.collections import Collection
57
+ from matplotlib.patches import Patch
58
+ top = -np.inf
59
+
60
+ def take(x, y):
61
+ nonlocal top
62
+ x, y = np.asarray(x, dtype=float).ravel(), np.asarray(y, dtype=float).ravel()
63
+ keep = np.isfinite(x) & np.isfinite(y) & (x >= x_lo) & (x <= x_hi)
64
+ if keep.any():
65
+ top = max(top, float(y[keep].max()))
66
+
67
+ for ln in ax.lines:
68
+ if ln.get_transform() is ax.transData or ln.get_transform().contains_branch(ax.transData):
69
+ take(ln.get_xdata(orig=False), ln.get_ydata(orig=False))
70
+ for coll in ax.collections:
71
+ if not isinstance(coll, Collection):
72
+ continue
73
+ offsets = coll.get_offsets()
74
+ if offsets is not None and len(offsets) and coll.get_offset_transform() is ax.transData:
75
+ off = np.ma.filled(np.ma.asarray(offsets, dtype=float), np.nan)
76
+ take(off[:, 0], off[:, 1])
77
+ elif coll.get_transform() is ax.transData or coll.get_transform().contains_branch(ax.transData):
78
+ for path in coll.get_paths():
79
+ v = path.vertices
80
+ take(v[:, 0], v[:, 1])
81
+ for patch in ax.patches:
82
+ if not isinstance(patch, Patch):
83
+ continue
84
+ try:
85
+ v = patch.get_path().transformed(patch.get_patch_transform()).vertices
86
+ except Exception: # a patch with no data-space geometry
87
+ continue
88
+ take(v[:, 0], v[:, 1])
89
+ return None if not np.isfinite(top) else top
90
+
91
+
92
+ def _stats_payload(row: dict, p_column: str) -> dict:
93
+ lo, hi = row.get("effect_size_ci_low"), row.get("effect_size_ci_high")
94
+ if lo is None and isinstance(row.get("effect_size_95_CI"), str):
95
+ try:
96
+ lo, hi = (float(v) for v in row["effect_size_95_CI"].strip("[]").split(","))
97
+ except ValueError:
98
+ lo = hi = None
99
+ n = [row.get("n_a"), row.get("n_b")] if row.get("n_a") is not None else row.get("n_total")
100
+ out = {
101
+ "test": row.get("sig_test_used"),
102
+ "statistic": row.get("test_statistic_value"),
103
+ "p": row.get("p-value"),
104
+ "pCorrected": row.get(p_column),
105
+ "correction": _CORRECTION_OF.get(p_column, p_column),
106
+ "effectSizeMethod": row.get("effect_size_method"),
107
+ "effectSize": row.get("effect_size_value"),
108
+ "ciLow": lo,
109
+ "ciHigh": hi,
110
+ "n": n,
111
+ }
112
+ if row.get("dof") is not None:
113
+ out["dof"] = row["dof"]
114
+ if row.get("alternative"):
115
+ out["alternative"] = row["alternative"]
116
+ return {k: v for k, v in out.items() if v is not None}
117
+
118
+
119
+ def brackets(ax, rows: Iterable[dict], *, positions: Optional[Dict[Any, float]] = None,
120
+ pairs: Optional[Iterable[Tuple[Any, Any]]] = None,
121
+ label: Union[str, Callable[[dict], str]] = "stars", p_column: str = "p_corrected_holm",
122
+ thresholds: Sequence[Tuple[float, str]] = THRESHOLDS, ns: bool = True,
123
+ top: Optional[float] = None, step: float = 0.06, tip: float = 0.02, **line_kw) -> list:
124
+ """Draw one significance bracket per ``fp.stats`` row, stacked so none overlap.
125
+
126
+ Parameters
127
+ ----------
128
+ ax
129
+ The axes the compared groups are plotted on.
130
+ rows
131
+ Reporting rows whose ``groups`` is the pair ``[a, b]`` (``fp.stats.pairwise``,
132
+ ``tukey_hsd``, ``games_howell``, ``dunn``; or any two-group row given ``names=``).
133
+ positions
134
+ ``{group name: x}``. Default: the axes' x tick labels (a glowbar / fluxbox / categorical
135
+ axis), which must name every group; ``GlowbarResult.positions`` and
136
+ ``FluxboxResult.positions`` give it directly.
137
+ pairs
138
+ Draw only these ``(a, b)`` pairs (either order), in this order of priority when several
139
+ rows name the same pair. Default: every row.
140
+ label
141
+ ``"stars"`` (``***``/``**``/``*``/``ns`` by ``thresholds``), ``"p"`` (``p = 0.003`` /
142
+ ``p < 0.001``), ``"both"`` (stars over the p), or a callable ``row -> str``.
143
+ p_column
144
+ The row column the label and the stacking read: ``"p_corrected_holm"`` (default),
145
+ ``"p_corrected_bh"`` or ``"p-value"``.
146
+ thresholds
147
+ ``((p, stars), …)``: the first threshold ``p`` falls under gives its label.
148
+ ns
149
+ Draw a bracket labelled ``ns`` for non-significant pairs (default). ``False`` omits them.
150
+ top
151
+ Start the stack at this y instead of above the data each bracket spans.
152
+ step, tip
153
+ Vertical step between brackets and the tip height, as fractions of the y range (on a log
154
+ axis, of the range in decades).
155
+ **line_kw
156
+ Passed to :func:`fluxplot.significance_bracket` (``color``, ``linewidth``, ``text_kw``, …).
157
+
158
+ Returns
159
+ -------
160
+ list
161
+ The bracket line artists, in drawing order (shortest span first). Each carries the
162
+ manifest ``stats`` payload of its row.
163
+ """
164
+ from .api import significance_bracket
165
+ rows = list(rows)
166
+ from_ticks = positions is None
167
+ if from_ticks:
168
+ positions = _positions_from_ticks(ax)
169
+ pos = {str(k): float(v) for k, v in positions.items()}
170
+ wanted = None if pairs is None else [(str(a), str(b)) for a, b in pairs]
171
+
172
+ items = []
173
+ for row in rows:
174
+ groups = row.get("groups")
175
+ if not groups or len(groups) != 2:
176
+ raise ValueError("brackets: every row needs groups=[a, b]; run the test with names=(a, b) or use fp.stats.pairwise")
177
+ a, b = (str(g) for g in groups)
178
+ if wanted is not None and (a, b) not in wanted and (b, a) not in wanted:
179
+ continue
180
+ for g in (a, b):
181
+ if g not in pos:
182
+ if from_ticks:
183
+ raise ValueError(f"brackets: positions= is needed — the x tick labels do not name group {g!r}")
184
+ raise KeyError(f"brackets: group {g!r} has no position; positions has {sorted(pos)}")
185
+ if p_column not in row:
186
+ raise KeyError(f"brackets: rows have no {p_column!r} column (have {sorted(row)})")
187
+ p = row[p_column]
188
+ significant = p is not None and math.isfinite(p) and p < max(t for t, _ in thresholds)
189
+ if not significant and not ns:
190
+ continue
191
+ items.append((a, b, row, p))
192
+ if wanted is not None:
193
+ order = {pair: i for i, pair in enumerate(wanted)}
194
+ items.sort(key=lambda it: min(order.get((it[0], it[1]), len(order)), order.get((it[1], it[0]), len(order))))
195
+ # shortest span first: a long bracket then rises above the short ones it covers
196
+ items.sort(key=lambda it: abs(pos[it[0]] - pos[it[1]]))
197
+
198
+ log = ax.get_yscale() == "log"
199
+ y0, y1 = ax.get_ylim()
200
+ if log:
201
+ span = abs(math.log10(y1) - math.log10(y0)) or 1.0
202
+ rise = lambda y, frac: y * 10 ** (frac * span) # noqa: E731
203
+ else:
204
+ span = abs(y1 - y0) or 1.0
205
+ rise = lambda y, frac: y + frac * span # noqa: E731
206
+ xs = sorted(set(pos.values()))
207
+ half = (min(np.diff(xs)) / 2.0) if len(xs) > 1 else 0.5
208
+
209
+ placed: List[Tuple[float, float, float]] = [] # (x_lo, x_hi, y of the bracket base)
210
+ out = []
211
+ for a, b, row, p in items:
212
+ x_lo, x_hi = sorted((pos[a], pos[b]))
213
+ if top is not None:
214
+ base = float(top)
215
+ else:
216
+ data_top = _data_top(ax, x_lo - half, x_hi + half)
217
+ base = rise(data_top if data_top is not None else y0, step)
218
+ for px_lo, px_hi, py in placed:
219
+ if x_lo <= px_hi and px_lo <= x_hi: # x-spans overlap (a shared category counts)
220
+ base = max(base, rise(py, step))
221
+ if callable(label):
222
+ text = str(label(row))
223
+ elif label == "stars":
224
+ text = stars(p, thresholds)
225
+ elif label == "p":
226
+ text = format_p(p)
227
+ elif label == "both":
228
+ text = f"{stars(p, thresholds)}\n{format_p(p)}"
229
+ else:
230
+ raise ValueError(f"brackets: label must be 'stars', 'p', 'both' or a callable, got {label!r}")
231
+ height = rise(base, tip) - base
232
+ br = significance_bracket(ax, x0=pos[a], x1=pos[b], y=base, label=text, between=(a, b), p=p,
233
+ name=f"{a}-{b}", height=height, stats=_stats_payload(row, p_column),
234
+ **line_kw)
235
+ placed.append((x_lo, x_hi, base))
236
+ out.append(br)
237
+ if out: # make room: the top bracket must lie inside the axes
238
+ highest = max(rise(py, tip) for _, _, py in placed)
239
+ top_tip = rise(highest, step / 2)
240
+ if top_tip > y1:
241
+ ax.set_ylim(y0, top_tip)
242
+ return out
@@ -0,0 +1,23 @@
1
+ """Deterministic standards-compliant JSON, preserving scientific float precision.
2
+
3
+ Missing observations are encoded by the data adapters, never by the serializer.
4
+ """
5
+ from __future__ import annotations
6
+ import json
7
+
8
+
9
+ def _native(obj):
10
+ if isinstance(obj, dict):
11
+ return {str(k): _native(v) for k, v in obj.items()}
12
+ if isinstance(obj, (list, tuple)):
13
+ return [_native(v) for v in obj]
14
+ if hasattr(obj, 'tolist'):
15
+ return _native(obj.tolist())
16
+ if isinstance(obj, float) and obj == 0:
17
+ return 0.0
18
+ return obj
19
+
20
+
21
+ def dumps(obj) -> str:
22
+ return json.dumps(_native(obj), ensure_ascii=False, allow_nan=False,
23
+ sort_keys=True, indent=2, separators=(',', ': ')) + '\n'
fluxplot/capture.py ADDED
@@ -0,0 +1,150 @@
1
+ """Capture each axis's data↔SVG-pixel mapping + scale type (spec §6).
2
+
3
+ Consumers must NOT have to reconstruct matplotlib's transform. We store, per axis, explicit
4
+ ``(data, svg)`` anchor pairs plus the scale type — two anchors + scale is the complete, transform-free
5
+ contract a morph / resize / agent needs. The conversion is the dpi-proof *fraction* method verified
6
+ in ``NOTES_matplotlib_svg.md`` §3 (exact round-trip against emitted marker positions).
7
+
8
+ Call :func:`capture_axes` AFTER ``fig.canvas.draw()`` (so layout/transforms are final) and BEFORE
9
+ the controlled render.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import numpy as np
14
+
15
+
16
+ def svg_viewbox(fig) -> tuple[float, float]:
17
+ """SVG user-space size in points — always ``figsize_inches * 72`` regardless of dpi."""
18
+ return fig.get_figwidth() * 72.0, fig.get_figheight() * 72.0
19
+
20
+
21
+ def data_to_svg(ax, fig, x: float, y: float) -> tuple[float, float]:
22
+ """Map a data point to SVG user-space coordinates (origin top-left, points)."""
23
+ disp = ax.transData.transform((x, y))
24
+ w_px, h_px = fig.bbox.width, fig.bbox.height
25
+ vbw, vbh = svg_viewbox(fig)
26
+ sx = disp[0] / w_px * vbw
27
+ sy = (1.0 - disp[1] / h_px) * vbh
28
+ return float(sx), float(sy)
29
+
30
+
31
+ def _log_base(axis) -> float:
32
+ try:
33
+ return float(axis.get_transform().base)
34
+ except Exception:
35
+ return 10.0
36
+
37
+
38
+ def tick_kinds(axis):
39
+ """Portable names for an axis' major tick locator and formatter classes: the consumer that
40
+ re-ticks a view (or redraws a colour key) picks the matching scheme."""
41
+ from matplotlib import ticker
42
+ loc, fmt = axis.get_major_locator(), axis.get_major_formatter()
43
+ if isinstance(loc, ticker.LogLocator):
44
+ locator = 'log'
45
+ elif isinstance(loc, ticker.FixedLocator):
46
+ locator = 'fixed'
47
+ elif isinstance(loc, ticker.MultipleLocator):
48
+ locator = 'multiple'
49
+ elif type(loc).__module__ == 'matplotlib.dates':
50
+ locator = 'date'
51
+ elif type(loc).__module__ == 'matplotlib.category':
52
+ locator = 'category'
53
+ else:
54
+ locator = 'auto'
55
+ if isinstance(fmt, ticker.LogFormatter):
56
+ formatter = 'log'
57
+ elif isinstance(fmt, ticker.PercentFormatter):
58
+ formatter = 'percent'
59
+ elif isinstance(fmt, ticker.ScalarFormatter):
60
+ formatter = 'sci' if fmt.get_offset() else 'plain'
61
+ elif isinstance(fmt, ticker.FixedFormatter):
62
+ formatter = 'fixed'
63
+ elif type(fmt).__module__ == 'matplotlib.dates':
64
+ formatter = 'date'
65
+ elif type(fmt).__module__ == 'matplotlib.category':
66
+ formatter = 'category'
67
+ else:
68
+ formatter = 'custom'
69
+ return locator, formatter
70
+
71
+
72
+ def _axis_capture(ax, fig, which: str) -> dict:
73
+ if which == "x":
74
+ scale = ax.get_xscale()
75
+ lo, hi = ax.get_xlim()
76
+ label = ax.get_xlabel()
77
+ y_ref = ax.get_ylim()[0]
78
+ endpoints = [(lo, y_ref), (hi, y_ref)]
79
+ mpl_axis = ax.xaxis
80
+ else:
81
+ scale = ax.get_yscale()
82
+ lo, hi = ax.get_ylim()
83
+ label = ax.get_ylabel()
84
+ x_ref = ax.get_xlim()[0]
85
+ endpoints = [(x_ref, lo), (x_ref, hi)]
86
+ mpl_axis = ax.yaxis
87
+
88
+ out: dict = {
89
+ "scale": scale,
90
+ "label": label,
91
+ "domain": [float(lo), float(hi)],
92
+ "anchors": [],
93
+ }
94
+ if scale == "log":
95
+ out["base"] = _log_base(mpl_axis)
96
+
97
+ supported = getattr(ax, "name", "rectilinear") == "rectilinear" and scale in ("linear", "log")
98
+ out["supported"] = supported
99
+ out["tickLocator"], out["tickFormatter"] = tick_kinds(mpl_axis)
100
+ out["ticks"] = [{"value": float(v), "label": t.get_text()}
101
+ for v, t in zip(mpl_axis.get_ticklocs(), mpl_axis.get_ticklabels()) if np.isfinite(v)]
102
+ converter = getattr(mpl_axis, "get_converter", lambda: getattr(mpl_axis, "converter", None))()
103
+ module = type(converter).__module__ if converter else ""
104
+ if module == "matplotlib.dates":
105
+ from matplotlib.dates import get_epoch
106
+ out["units"] = {"kind": "date", "epoch": get_epoch(), "unit": "day"}
107
+ elif module == "matplotlib.category":
108
+ out["units"] = {"kind": "category"}
109
+ for (dx, dy), data_val in zip(endpoints, (lo, hi)):
110
+ if not supported:
111
+ break
112
+ sx, sy = data_to_svg(ax, fig, dx, dy)
113
+ out["anchors"].append({"data": float(data_val), "svg": sx if which == "x" else sy})
114
+ return out
115
+
116
+
117
+ def capture_axes(ax, fig, twins=()) -> dict:
118
+ """Return ``{"x": {...}, "y": {...}, "pixelBox": {...}}`` for one Axes — plus ``"y2"`` /
119
+ ``"x2"`` for each twin axes (``[(axes, "y2" | "x2")]``) sharing its frame."""
120
+ # plot-area rectangle in SVG coords (convenience; the SVG clipPath stays authoritative).
121
+ vbw, vbh = svg_viewbox(fig)
122
+ box = ax.get_window_extent()
123
+ sx0, sx1 = box.x0 / fig.bbox.width * vbw, box.x1 / fig.bbox.width * vbw
124
+ sy0, sy1 = (1 - box.y1 / fig.bbox.height) * vbh, (1 - box.y0 / fig.bbox.height) * vbh
125
+ out = {
126
+ "projection": getattr(ax, "name", "rectilinear"),
127
+ "x": _axis_capture(ax, fig, "x"),
128
+ "y": _axis_capture(ax, fig, "y"),
129
+ "pixelBox": {
130
+ "x0": min(sx0, sx1),
131
+ "y0": min(sy0, sy1),
132
+ "x1": max(sx0, sx1),
133
+ "y1": max(sy0, sy1),
134
+ },
135
+ }
136
+ for twin, which in twins:
137
+ if which not in out: # the first twin of each kind; a third value axis has no slot
138
+ out[which] = _axis_capture(twin, fig, "y" if which == "y2" else "x")
139
+ functions = getattr(twin, "_functions", None)
140
+ if functions is not None and callable(functions[0]):
141
+ # a secondary axis: the parent → secondary transform as samples across the parent's range
142
+ lo, hi = (ax.get_xlim() if which == "x2" else ax.get_ylim())
143
+ grid = np.linspace(lo, hi, 9)
144
+ try:
145
+ mapped = np.asarray(functions[0](grid), dtype=float)
146
+ out[which]["secondary"] = {"of": "x" if which == "x2" else "y",
147
+ "samples": [[float(a), float(b)] for a, b in zip(grid, mapped) if np.isfinite(b)]}
148
+ except Exception: # a transform that rejects the grid: the axis stays, unsampled
149
+ out[which]["secondary"] = {"of": "x" if which == "x2" else "y", "samples": []}
150
+ return out