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/fits.py ADDED
@@ -0,0 +1,240 @@
1
+ """``fp.regression`` and ``fp.kde``: fitted curves drawn as addressable series with the fit recorded.
2
+
3
+ A regression is three parts of one series — the points (``<series>.point.k``), the fit line
4
+ (``<series>.fit``) and its confidence band (``<series>.band``) — and a ``regression`` payload in
5
+ the manifest: the coefficients, R², the p-value, n and the confidence level, so a caption or a
6
+ consumer can state the fit exactly as drawn. A KDE is a line (and optionally its fill) with the
7
+ evaluation grid and density recorded, so it can be re-drawn without the raw values.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Dict
13
+
14
+ import numpy as np
15
+
16
+ from . import data as _data
17
+ from . import tagger as _tagger
18
+ from .descriptors import Mark
19
+
20
+ __all__ = ["regression", "kde", "RegressionResult", "KdeResult", "lowess", "REGRESSION_KINDS"]
21
+
22
+ REGRESSION_KINDS = ("linear", "poly", "lowess")
23
+ _BOOT_SEED = 0
24
+
25
+
26
+ @dataclass
27
+ class RegressionResult:
28
+ ax: Any
29
+ #: the fit ``Line2D``, the band ``PolyCollection`` (or ``None``) and the points (or ``None``)
30
+ line: Any
31
+ band: Any
32
+ points: Any
33
+ #: the evaluation grid and the fitted curve with its confidence bounds
34
+ grid: np.ndarray = field(repr=False, default=None)
35
+ fit: np.ndarray = field(repr=False, default=None)
36
+ lower: np.ndarray = field(repr=False, default=None)
37
+ upper: np.ndarray = field(repr=False, default=None)
38
+ #: ``{kind, degree, coefficients, r2, p, n, ci, …}`` as the manifest records it
39
+ stats: Dict[str, Any] = field(default_factory=dict)
40
+
41
+
42
+ @dataclass
43
+ class KdeResult:
44
+ ax: Any
45
+ line: Any
46
+ area: Any
47
+ grid: np.ndarray = field(repr=False, default=None)
48
+ density: np.ndarray = field(repr=False, default=None)
49
+ stats: Dict[str, Any] = field(default_factory=dict)
50
+
51
+
52
+ def _clean_xy(x, y, who):
53
+ x = np.asarray(x, dtype=float).ravel()
54
+ y = np.asarray(y, dtype=float).ravel()
55
+ if x.size != y.size:
56
+ raise ValueError(f"{who}: x and y have different lengths ({x.size} vs {y.size})")
57
+ ok = np.isfinite(x) & np.isfinite(y)
58
+ if ok.sum() < 3:
59
+ raise ValueError(f"{who} needs at least 3 finite (x, y) pairs, got {int(ok.sum())}")
60
+ return x[ok], y[ok]
61
+
62
+
63
+ def lowess(x, y, grid, frac=2 / 3, it=3):
64
+ """Cleveland's (1979) locally weighted regression: at each grid point a linear fit weighted by
65
+ the tricube of distance to the nearest ``frac`` of the data, with ``it`` robustifying
66
+ iterations that down-weight large residuals (bisquare). Returns the smooth on ``grid``."""
67
+ x = np.asarray(x, dtype=float)
68
+ y = np.asarray(y, dtype=float)
69
+ grid = np.asarray(grid, dtype=float)
70
+ n = x.size
71
+ k = max(int(np.ceil(frac * n)), 2)
72
+ robust = np.ones(n)
73
+
74
+ def fit_at(x0, weights_extra):
75
+ d = np.abs(x - x0)
76
+ h = np.sort(d)[min(k, n) - 1] or 1e-12
77
+ w = np.clip(1 - (d / h) ** 3, 0, 1) ** 3 * weights_extra
78
+ sw = w.sum()
79
+ if sw <= 0:
80
+ return np.nan
81
+ xm = (w * x).sum() / sw
82
+ ym = (w * y).sum() / sw
83
+ var = (w * (x - xm) ** 2).sum()
84
+ slope = (w * (x - xm) * (y - ym)).sum() / var if var > 0 else 0.0
85
+ return ym + slope * (x0 - xm)
86
+
87
+ for _ in range(max(it, 0)):
88
+ fitted = np.array([fit_at(xi, robust) for xi in x])
89
+ resid = y - fitted
90
+ s = np.median(np.abs(resid)) or 1e-12
91
+ robust = np.clip(1 - (resid / (6 * s)) ** 2, 0, 1) ** 2
92
+ return np.array([fit_at(g, robust) for g in grid])
93
+
94
+
95
+ def _poly_ci(x, y, grid, degree, ci):
96
+ """Polynomial least squares with the confidence band of the mean response."""
97
+ from scipy import stats as _sp
98
+ n = x.size
99
+ dof = n - (degree + 1)
100
+ if dof < 1:
101
+ raise ValueError(f"regression: degree {degree} needs more than {degree + 1} points (n={n})")
102
+ coef, cov = np.polyfit(x, y, degree, cov=True)
103
+ fit_grid = np.polyval(coef, grid)
104
+ fitted = np.polyval(coef, x)
105
+ design = np.vander(grid, degree + 1) # highest power first, as polyfit's coefficients
106
+ se = np.sqrt(np.einsum("ij,jk,ik->i", design, cov, design))
107
+ t = _sp.t.ppf(0.5 + ci / 2, dof)
108
+ ss_res = float(((y - fitted) ** 2).sum())
109
+ ss_tot = float(((y - y.mean()) ** 2).sum())
110
+ r2 = 1 - ss_res / ss_tot if ss_tot > 0 else float("nan")
111
+ if degree == 1:
112
+ p = float(_sp.linregress(x, y).pvalue)
113
+ else:
114
+ f = ((ss_tot - ss_res) / degree) / (ss_res / dof) if ss_res > 0 else float("inf")
115
+ p = float(_sp.f.sf(f, degree, dof))
116
+ return coef, fit_grid, fit_grid - t * se, fit_grid + t * se, r2, p, dof
117
+
118
+
119
+ def regression(ax, x, y, *, series, kind="linear", ci=0.95, degree=1, frac=2 / 3, points=True, n_grid=100,
120
+ n_boot=200, label=None, color=None, band_kw=None, point_kw=None, **line_kw) -> RegressionResult:
121
+ """Fit ``y ~ x`` and draw the fit line, its confidence band and (by default) the points as one
122
+ series, recording the fit in the manifest.
123
+
124
+ Parameters
125
+ ----------
126
+ kind
127
+ ``"linear"`` (a straight line), ``"poly"`` (a polynomial of ``degree``) or ``"lowess"``
128
+ (Cleveland's locally weighted regression with span ``frac``).
129
+ ci
130
+ The confidence level of the band: for linear / poly the t-interval of the mean response;
131
+ for lowess a seeded percentile bootstrap of the smooth (``n_boot`` resamples).
132
+ points
133
+ Draw the observations as ``<series>.point.k`` too.
134
+ color
135
+ The series colour (line, band and points); default the cycle's next colour.
136
+ band_kw, point_kw, **line_kw
137
+ Extra keywords for the band (``fill_between``), the points (``scatter``) and the fit line.
138
+
139
+ Returns
140
+ -------
141
+ RegressionResult
142
+ With ``stats = {kind, degree, coefficients, r2, p, n, ci, frac?}`` (``coefficients`` in
143
+ ``numpy.polyfit`` order, highest power first; ``None`` for lowess, whose ``p`` is ``None``).
144
+ """
145
+ from .api import _series_color, band as _band, scatter as _scatter
146
+ if kind not in REGRESSION_KINDS:
147
+ raise ValueError(f"regression: kind must be one of {REGRESSION_KINDS}, got {kind!r}")
148
+ if not 0 < ci < 1:
149
+ raise ValueError(f"regression: ci must lie in (0, 1), got {ci!r}")
150
+ xs, ys = _clean_xy(x, y, "regression")
151
+ grid = np.linspace(xs.min(), xs.max(), int(n_grid))
152
+ deg = 1 if kind == "linear" else int(degree)
153
+ if kind in ("linear", "poly"):
154
+ coef, fit, lo, hi, r2, p, dof = _poly_ci(xs, ys, grid, deg, ci)
155
+ stats = {"kind": kind, "degree": deg, "coefficients": [float(c) for c in coef], "r2": float(r2), "p": p,
156
+ "n": int(xs.size), "ci": float(ci), "dof": int(dof)}
157
+ else:
158
+ fit = lowess(xs, ys, grid, frac=frac)
159
+ rng = np.random.default_rng(_BOOT_SEED)
160
+ draws = []
161
+ for _ in range(int(n_boot)):
162
+ idx = rng.integers(0, xs.size, xs.size)
163
+ draws.append(lowess(xs[idx], ys[idx], grid, frac=frac))
164
+ draws = np.array(draws)
165
+ lo, hi = np.nanpercentile(draws, [50 * (1 - ci), 50 * (1 + ci)], axis=0)
166
+ fitted = np.interp(xs, grid, fit)
167
+ ss_res = float(((ys - fitted) ** 2).sum())
168
+ ss_tot = float(((ys - ys.mean()) ** 2).sum())
169
+ stats = {"kind": "lowess", "degree": None, "coefficients": None,
170
+ "r2": 1 - ss_res / ss_tot if ss_tot > 0 else float("nan"), "p": None, "n": int(xs.size),
171
+ "ci": float(ci), "frac": float(frac), "bootstrap": int(n_boot)}
172
+
173
+ kw = dict(line_kw)
174
+ if color is not None:
175
+ kw["color"] = color
176
+ _series_color(series, kw)
177
+ the_color = kw.get("color")
178
+ reg = _tagger.registry_for(ax.figure)
179
+ # the fit is registered first, so the series is a "regression" (its first mark names its kind);
180
+ # drawn first too, it still sits above the points (a line's zorder beats a collection's)
181
+ (ln,) = ax.plot(grid, fit, label=label, **kw)
182
+ if the_color is None:
183
+ the_color = ln.get_color()
184
+ payload = {**stats, "grid": _data.values(grid), "fit": _data.values(fit)}
185
+ reg.add(Mark(role="line", series=series, name="fit", kind="regression", x=_data.values(grid), y=_data.values(fit),
186
+ label=label, artists=[ln], data={"regression": payload}))
187
+ pts = None
188
+ if points:
189
+ pkw = dict(point_kw or {})
190
+ pkw.setdefault("s", 14)
191
+ pkw.setdefault("alpha", 0.6)
192
+ pkw.setdefault("color", the_color)
193
+ pts = _scatter(ax, xs, ys, series=series, **pkw)
194
+ bkw = dict(band_kw or {})
195
+ bkw.setdefault("color", the_color)
196
+ band = _band(ax, grid, lo, hi, series=series, what=f"{ci:.0%} CI", **bkw)
197
+ return RegressionResult(ax=ax, line=ln, band=band, points=pts, grid=grid, fit=fit, lower=np.asarray(lo),
198
+ upper=np.asarray(hi), stats=stats)
199
+
200
+
201
+ def kde(ax, values, *, series, bw="scott", fill=False, n_grid=200, cut=3.0, label=None, color=None,
202
+ fill_kw=None, **line_kw) -> KdeResult:
203
+ """A Gaussian kernel density estimate drawn as a line (``<series>.line``; ``fill=True`` adds
204
+ ``<series>.fill``), recording the grid, the density and the bandwidth in the manifest.
205
+
206
+ ``bw`` is scipy's ``bw_method`` (``"scott"``, ``"silverman"``, a number or a callable);
207
+ the grid runs ``cut`` bandwidths past the data on either side.
208
+ """
209
+ from scipy import stats as _sp
210
+ from .api import _series_color
211
+ v = np.asarray(values, dtype=float).ravel()
212
+ v = v[np.isfinite(v)]
213
+ if v.size < 2:
214
+ raise ValueError(f"kde needs at least 2 finite values, got {v.size}")
215
+ if v.std(ddof=1) == 0:
216
+ raise ValueError("kde: the values have zero variance")
217
+ estimator = _sp.gaussian_kde(v, bw_method=bw)
218
+ bandwidth = float(estimator.factor * v.std(ddof=1))
219
+ grid = np.linspace(v.min() - cut * bandwidth, v.max() + cut * bandwidth, int(n_grid))
220
+ density = estimator(grid)
221
+ kw = dict(line_kw)
222
+ if color is not None:
223
+ kw["color"] = color
224
+ _series_color(series, kw)
225
+ (ln,) = ax.plot(grid, density, label=label, **kw)
226
+ reg = _tagger.registry_for(ax.figure)
227
+ payload = {"grid": _data.values(grid), "density": _data.values(density), "bandwidth": bandwidth,
228
+ "method": bw if isinstance(bw, str) else ("callable" if callable(bw) else float(bw)), "n": int(v.size)}
229
+ reg.add(Mark(role="line", series=series, kind="kde", live_data=True, x=None, y=None, label=label, artists=[ln],
230
+ data={"kde": payload}))
231
+ area = None
232
+ if fill:
233
+ fkw = dict(fill_kw or {})
234
+ fkw.setdefault("alpha", 0.25)
235
+ fkw.setdefault("linewidth", 0)
236
+ fkw.setdefault("color", ln.get_color())
237
+ area = ax.fill_between(grid, 0.0, density, **fkw)
238
+ reg.add(Mark(role="area", series=series, name="fill", kind="kde", artists=[area],
239
+ data={"band": {"x": _data.values(grid), "y1": _data.values(density), "y2": [0.0] * int(n_grid)}}))
240
+ return KdeResult(ax=ax, line=ln, area=area, grid=grid, density=density, stats=payload)
fluxplot/glb.py ADDED
@@ -0,0 +1,84 @@
1
+ """Deterministic numpy-only binary glTF 2.0 writer (scene3d contract 0.1.0)."""
2
+ from __future__ import annotations
3
+ import json
4
+ import struct
5
+ import numpy as np
6
+ from matplotlib.colors import to_rgba
7
+ from .version import __version__
8
+
9
+
10
+ def linear_rgb(rgb):
11
+ rgb=np.asarray(rgb,dtype=float)
12
+ return np.where(rgb<=.04045,rgb/12.92,((rgb+.055)/1.055)**2.4)
13
+
14
+
15
+ def vertex_normals(vertices, faces):
16
+ out=np.zeros_like(vertices,dtype=np.float64)
17
+ # Convert before subtraction/cross products: finite float32 coordinates can
18
+ # overflow float32 area products while their float64 products remain finite.
19
+ t=np.asarray(vertices,dtype=np.float64)[faces]
20
+ area=np.cross(t[:,1]-t[:,0],t[:,2]-t[:,0])
21
+ for i in range(3): np.add.at(out,faces[:,i],area)
22
+ size=np.linalg.norm(out,axis=1)
23
+ out[size==0]=(0,1,0)
24
+ return (out/np.linalg.norm(out,axis=1,keepdims=True)).astype('<f4')
25
+
26
+
27
+ def _finite32(a, name):
28
+ with np.errstate(over='ignore'): a=np.asarray(a,dtype='<f4')
29
+ if not np.isfinite(a).all(): raise ValueError(f'{name} cannot be represented by finite float32 values')
30
+ return a
31
+
32
+
33
+ def write_glb(scene):
34
+ """Serialize one GLB. No timestamps, external resources, textures, or vertex welding."""
35
+ if not scene.parts: raise ValueError('cannot save an empty Scene3D; add a mesh first with fp.mesh3d(sc, ...)')
36
+ scene._resolve_pending_view(strict=True)
37
+ binary=bytearray()
38
+ doc=dict(asset={'version':'2.0','generator':f'fluxplot {__version__}'},buffers=[{}],bufferViews=[],accessors=[],meshes=[],nodes=[],materials=[],scenes=[{'nodes':list(range(len(scene.parts)))}],scene=0)
39
+ def accessor(a, kind, component, *, bounds=False, normalized=False, target=None, stride=None):
40
+ a=np.ascontiguousarray(a); binary.extend(b'\0'*(-len(binary)%4))
41
+ view={'buffer':0,'byteOffset':len(binary),'byteLength':a.nbytes}
42
+ if target is not None: view['target']=target
43
+ if stride is not None: view['byteStride']=stride
44
+ doc['bufferViews'].append(view); binary.extend(a.tobytes())
45
+ entry={'bufferView':len(doc['bufferViews'])-1,'componentType':component,'count':len(a),'type':kind}
46
+ if bounds: entry.update(min=a.min(axis=0).tolist(),max=a.max(axis=0).tolist())
47
+ if normalized: entry['normalized']=True
48
+ doc['accessors'].append(entry)
49
+ return len(doc['accessors'])-1
50
+ rotation=scene.to_world[:3,:3]
51
+ for part in scene.parts:
52
+ v=_finite32(part.vertices@rotation.T,'positions')
53
+ f=part.faces.astype('<u2' if part.faces.max()<65536 else '<u4')
54
+ n=vertex_normals(v,f)
55
+ attrs={'POSITION':accessor(v,'VEC3',5126,bounds=True,target=34962),'NORMAL':accessor(n,'VEC3',5126,target=34962)}
56
+ if part.values is not None:
57
+ valid=np.isfinite(part.values)
58
+ value=_finite32(np.where(valid,part.values,0),'values')
59
+ attrs['_VALUE']=accessor(value,'SCALAR',5126,target=34962)
60
+ if not valid.all(): attrs['_VALID']=accessor(np.column_stack([valid,np.zeros((len(valid),3),dtype='u1')]).astype('u1'),'SCALAR',5121,target=34962,stride=4)
61
+ if part.colors is not None:
62
+ rgba=np.asarray(part.colors,dtype=float).copy(); rgba[:,:3]=linear_rgb(rgba[:,:3])
63
+ attrs['COLOR_0']=accessor(np.rint(np.clip(rgba,0,1)*255).astype('u1'),'VEC4',5121,normalized=True,target=34962)
64
+ rgba=to_rgba(part.color); base=[*linear_rgb(rgba[:3]).tolist(),rgba[3]]
65
+ # Vertex colors already carry the complete map; white avoids tinting them twice.
66
+ # The part colour's alpha (fp.surface3d(alpha=...)) still applies as opacity.
67
+ if part.colors is not None: base=[1,1,1,rgba[3] if rgba[3]<1 else 1]
68
+ material={'name':part.id,'pbrMetallicRoughness':{'baseColorFactor':base,'metallicFactor':0,'roughnessFactor':.6},'doubleSided':True}
69
+ if rgba[3]<1 or (part.colors is not None and (part.colors[:,3]<1).any()): material['alphaMode']='BLEND'
70
+ doc['materials'].append(material)
71
+ prim={'attributes':attrs,'indices':accessor(f.ravel(),'SCALAR',5123 if f.dtype.itemsize==2 else 5125,target=34963),'material':len(doc['materials'])-1,'mode':4}
72
+ mesh={'name':part.id,'primitives':[prim]}
73
+ if part.states:
74
+ prim['targets']=[]
75
+ for name,target in part.states.items():
76
+ world=_finite32(target@rotation.T,'shape positions')
77
+ prim['targets'].append({'POSITION':accessor(_finite32(world-v,'shape deltas'),'VEC3',5126,bounds=True,target=34962),'NORMAL':accessor(vertex_normals(world,f)-n,'VEC3',5126,target=34962)})
78
+ mesh['extras']={'targetNames':list(part.states)}
79
+ mesh['weights']=[scene._view.get('states',{}).get(name,0) for name in part.states]
80
+ doc['meshes'].append(mesh); doc['nodes'].append({'name':part.id,'mesh':len(doc['meshes'])-1})
81
+ doc['buffers'][0]['byteLength']=len(binary)
82
+ js=json.dumps(doc,sort_keys=True,separators=(',',':'),allow_nan=False).encode('utf8')
83
+ js+=b' '*(-len(js)%4); binary.extend(b'\0'*(-len(binary)%4))
84
+ return struct.pack('<4sII',b'glTF',2,28+len(js)+len(binary))+struct.pack('<I4s',len(js),b'JSON')+js+struct.pack('<I4s',len(binary),b'BIN\0')+binary
fluxplot/ids.py ADDED
@@ -0,0 +1,173 @@
1
+ """Deterministic semantic IDs — the public, long-lived join key (spec §7, open Q6).
2
+
3
+ An id is a dotted path of `[a-z0-9-]` segments; the dot is the only separator and the index is
4
+ 0-based:
5
+
6
+ control.line control.point.3 axis.x.title axis.x.tick.2 legend annotation.peak
7
+
8
+ matplotlib's own ids use ``_`` + hex hashes (``line2d_7``, ``p3dcf139bf4``) and never contain dots,
9
+ so this namespace is provably disjoint from anything matplotlib autogenerates.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+ # Structural roots that stand alone at the head of an id (not series-scoped).
16
+ STRUCTURAL_ROOTS = frozenset(
17
+ {
18
+ "figure",
19
+ "panel",
20
+ "plot-area",
21
+ "axis",
22
+ "legend",
23
+ "colorbar",
24
+ "title",
25
+ "background",
26
+ "annotation",
27
+ "reference-line",
28
+ "highlight-region",
29
+ "significance-bracket",
30
+ "label",
31
+ }
32
+ )
33
+
34
+ _SLUG_DROP = re.compile(r"[^a-z0-9-]+")
35
+ _SLUG_DASHES = re.compile(r"-+")
36
+ _SEGMENT_RE = re.compile(r"^[a-z0-9-]+$")
37
+
38
+ # Characters that are spelled out rather than dropped: Greek letters (α → alpha), the micro sign,
39
+ # degrees, percent and the common maths signs scientific names carry.
40
+ TRANSLITERATE = {
41
+ "α": "alpha", "β": "beta", "γ": "gamma", "δ": "delta", "ε": "epsilon", "ζ": "zeta", "η": "eta",
42
+ "θ": "theta", "ι": "iota", "κ": "kappa", "λ": "lambda", "μ": "mu", "ν": "nu", "ξ": "xi",
43
+ "ο": "omicron", "π": "pi", "ρ": "rho", "σ": "sigma", "ς": "sigma", "τ": "tau", "υ": "upsilon",
44
+ "φ": "phi", "χ": "chi", "ψ": "psi", "ω": "omega", "ϵ": "epsilon", "ϑ": "theta", "ϕ": "phi",
45
+ "µ": "mu", "°": "deg", "%": "pct", "‰": "permille", "±": "pm", "×": "x", "∞": "inf",
46
+ "→": "to", "←": "from", "≤": "le", "≥": "ge", "≠": "ne", "−": "-", "–": "-", "—": "-", "‑": "-",
47
+ "&": "and", "+": "plus",
48
+ }
49
+ # Punctuation whose loss never confuses two names: sentence marks and quotes.
50
+ _SOFT_DROP = set(".,;:!?'\u2019\"`")
51
+ _HASH_LEN = 6
52
+
53
+
54
+ def _fold(name: str) -> str:
55
+ """Lowercase, accents stripped, superscripts/subscripts and Greek spelled out."""
56
+ import unicodedata
57
+ out = []
58
+ for ch in unicodedata.normalize("NFKD", name):
59
+ if unicodedata.category(ch) == "Mn": # a combining accent
60
+ continue
61
+ low = ch.lower()
62
+ out.append(TRANSLITERATE.get(low, low))
63
+ return "".join(out)
64
+
65
+
66
+ def _slug_parts(name: object):
67
+ raw = str(name).strip()
68
+ folded = _fold(raw).replace("_", "-").replace(" ", "-")
69
+ dropped = [ch for ch in set(_SLUG_DROP.findall(folded.replace("-", ""))) for ch in ch]
70
+ lossy = any(ch not in _SOFT_DROP and not ch.isspace() for ch in dropped)
71
+ s = _SLUG_DROP.sub("-", folded)
72
+ s = _SLUG_DASHES.sub("-", s).strip("-")
73
+ return raw, s, lossy
74
+
75
+
76
+ def slugify(name: object) -> str:
77
+ """Turn an arbitrary series/category name into a stable id segment.
78
+
79
+ Deterministic: accents folded, Greek letters / ``µ`` / ``°`` / ``%`` and super- or subscript
80
+ digits spelled out (``α`` → ``alpha``, ``CO₂`` → ``co2``), lowercase, spaces/underscores to
81
+ ``-``, other characters dropped, repeated ``-`` collapsed and trimmed. When dropping
82
+ characters lost information that could make two names collide (brackets, slashes, symbols —
83
+ ``IL-6 (pg/mL)`` vs ``IL-6 [pg/mL]``), or when nothing legible is left, a 6-hex-digit hash of
84
+ the original name is appended, so distinct names give distinct, stable ids. Empty input falls
85
+ back to ``series``.
86
+ """
87
+ raw, s, lossy = _slug_parts(name)
88
+ if raw and (not s or s == "series" or lossy):
89
+ import hashlib
90
+ s = (s or "series") + "-" + hashlib.sha1(raw.encode("utf-8")).hexdigest()[:_HASH_LEN]
91
+ return s or "series"
92
+
93
+
94
+ def legacy_slugify(name: object) -> str:
95
+ """The pre-0.3.2 slug (lowercase, ``_``/space → ``-``, everything else dropped): what an
96
+ older manifest called the same series, for ``idAliases``."""
97
+ s = str(name).strip().lower().replace("_", "-").replace(" ", "-")
98
+ s = _SLUG_DROP.sub("-", s)
99
+ s = _SLUG_DASHES.sub("-", s).strip("-")
100
+ return s or "series"
101
+
102
+
103
+ def is_valid_segment(seg: str) -> bool:
104
+ return bool(_SEGMENT_RE.match(seg))
105
+
106
+
107
+ def join(*segments: object) -> str:
108
+ """Compose an id from segments, validating each is a legal segment."""
109
+ parts = []
110
+ for seg in segments:
111
+ if seg is None:
112
+ continue
113
+ seg = str(seg)
114
+ if not is_valid_segment(seg):
115
+ raise ValueError(f"invalid id segment: {seg!r}")
116
+ parts.append(seg)
117
+ if not parts:
118
+ raise ValueError("an id needs at least one segment")
119
+ return ".".join(parts)
120
+
121
+
122
+ def series_root(series: object) -> str:
123
+ """Slug for a series, disambiguated from structural roots.
124
+
125
+ A series literally named "legend" becomes ``legend-series`` so structural roots stay unambiguous.
126
+ """
127
+ slug = slugify(series)
128
+ return f"{slug}-series" if slug in STRUCTURAL_ROOTS else slug
129
+
130
+
131
+ def series_id(series: object, role: str | None = None, index: int | None = None) -> str:
132
+ """``control`` → ``control``; +role → ``control.line``; +index → ``control.point.3``."""
133
+ segs: list[object] = [series_root(series)]
134
+ if role is not None:
135
+ segs.append(role)
136
+ if index is not None:
137
+ segs.append(int(index))
138
+ return join(*segs)
139
+
140
+
141
+ def axis_id(which: str, part: str | None = None, index: int | None = None) -> str:
142
+ """``axis.x``, ``axis.x.title``, ``axis.x.tick.2`` (which ∈ x, y, x2, y2)."""
143
+ segs: list[object] = ["axis", which]
144
+ if part is not None:
145
+ segs.append(part)
146
+ if index is not None:
147
+ segs.append(int(index))
148
+ return join(*segs)
149
+
150
+
151
+ class IdAllocator:
152
+ """Per-plot id allocation with deterministic collision handling.
153
+
154
+ Same inputs → same ids. If a candidate id is already taken, later ones get a deterministic
155
+ ``-2``, ``-3``, … suffix on the final segment (by insertion order).
156
+ """
157
+
158
+ def __init__(self) -> None:
159
+ self._seen: set[str] = set()
160
+
161
+ def take(self, candidate: str) -> str:
162
+ if candidate not in self._seen:
163
+ self._seen.add(candidate)
164
+ return candidate
165
+ head, _, tail = candidate.rpartition(".")
166
+ i = 2
167
+ while True:
168
+ suffixed = f"{tail}-{i}"
169
+ new = f"{head}.{suffixed}" if head else suffixed
170
+ if new not in self._seen:
171
+ self._seen.add(new)
172
+ return new
173
+ i += 1