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
@@ -0,0 +1,769 @@
1
+ """``fp.glowbar`` — the glowbar, FluxPlot's first *signature* plot.
2
+
3
+ A glowbar shows every observation of a categorical comparison as a dot and, beside each group, a
4
+ slim bar that *glows*: its ink is densest at the centre of the distribution and thins out toward hard
5
+ caps at the interval ends. Three statistics are read straight off that one mark:
6
+
7
+ * the **interval** (default: mean ± SEM; or the interquartile range, the box of a box plot) — the
8
+ glowing bar and its two caps;
9
+ * the **mean** — a heavy line across the bar, lifted off the glow by a thin halo;
10
+ * the **median** — a V-notch cut into both edges of the bar.
11
+
12
+ With ``units=`` (a subject / animal / cell column) every unit keeps a **fixed lane and a fixed
13
+ colour**. Both are derived from the table, never from the plotted values, so plots of different
14
+ measures made from the same table put "animal 8" at the same spot, in the same colour, in every
15
+ panel. The per-unit colours are equal *perceptual* steps of each group's ColorBrewer map (sampling a
16
+ map evenly in its parameter crowds its dark end), dealt across lanes so neighbouring lanes always
17
+ contrast, and outlined in a deeper shade of themselves so the palest dots stay crisp on white.
18
+ ``connect_identical_points_across_x_values=True`` joins each unit's points across the x categories
19
+ for paired / repeated-measures designs.
20
+
21
+ Everything drawn is a named part, so a glowbar round-trips through Flux like any other FluxPlot:
22
+
23
+ ======================= =============================================== ==========
24
+ part default id role
25
+ ======================= =============================================== ==========
26
+ interval glow ``<category>.glow`` ``box``
27
+ interval caps ``<category>.caps`` ``cap``
28
+ mean line ``<category>.mean`` ``mean``
29
+ median notch ``<category>.median`` ``median``
30
+ a unit's point(s) ``<unit>.points``, ``<unit>.point.<k>`` ``point``
31
+ a unit's connector ``<unit>.line`` ``line``
32
+ points (no ``units``) ``<category>.points``, ``<category>.point.<k>`` ``point``
33
+ ======================= =============================================== ==========
34
+
35
+ Each category series also carries a ``glowbar`` payload in the manifest with the exact statistics
36
+ drawn (n, mean, median, sd, sem, q1, q3, the interval ends, the glow centre, the group colour), and
37
+ each unit series carries its identity (the unit value and the categories it appears in) — a reader
38
+ of the ``.fluxplot.json`` never has to reverse-engineer pixels. Series names default to the category
39
+ and unit values and can be overridden with ``series=`` / ``unit_series=``.
40
+
41
+ Example
42
+ -------
43
+ >>> import fluxplot as fp, matplotlib.pyplot as plt
44
+ >>> from fluxplot import style as fx
45
+ >>> fx.use_light()
46
+ >>> fig, ax = plt.subplots(figsize=(1.6, 1.8))
47
+ >>> gb = fp.glowbar(df, x="condition", y="APP/GAPDH", units="subject", ax=ax)
48
+ >>> gb.stats["SD"]["median"]
49
+ 2.839...
50
+ >>> fp.save(fig, "plots/app_gapdh.svg")
51
+ """
52
+ from __future__ import annotations
53
+
54
+ from dataclasses import dataclass, field
55
+ from typing import Any, Callable, Mapping, Optional, Sequence, Union
56
+
57
+ import numpy as np
58
+
59
+ from .. import ids as _ids
60
+ from .. import tagger as _tagger
61
+ from ..descriptors import Mark
62
+ from . import _colour
63
+ from ._colour import even_shades, interleaved_order
64
+ from ._colour import perceptual as _perceptual # noqa: F401 (re-exported for tests / callers)
65
+
66
+ __all__ = ["glowbar", "GlowbarResult", "even_shades", "interleaved_order"]
67
+
68
+ #: Per-category ColorBrewer maps used when ``palette`` is not given (cycled for more categories).
69
+ DEFAULT_MAPS = ("YlGnBu", "YlOrRd", "RdPu", "BuGn", "Purples", "YlOrBr")
70
+ #: Flexoki base-300: a quiet neutral for connectors, so paired lines never compete with the points.
71
+ #: The default connector colour is the active theme's grid token when a theme is on (a neutral
72
+ #: tuned to that ground); this grey is the fallback.
73
+ CONNECT_GREY = "#B7B5AC"
74
+
75
+
76
+ def _connect_colour(connect_color, ground=None):
77
+ """The connectors' colour: the caller's, else the theme's grid neutral when it still reads
78
+ against the ground (WCAG ≥ 1.5 — the light theme's grid is too faint on white), else the
79
+ historic Flexoki base-300."""
80
+ if connect_color is not None:
81
+ return connect_color
82
+ from .. import style as _style
83
+ if _style.ACTIVE is not None:
84
+ grid = _style.ACTIVE["tokens"]["grid"]
85
+ if ground is None:
86
+ return grid
87
+ from ..colorcheck import contrast
88
+ from matplotlib.colors import to_hex
89
+ if contrast(grid, to_hex(ground)) >= 1.5:
90
+ return grid
91
+ return CONNECT_GREY
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # data access — pandas, polars, a dict of columns, or bare array-likes
96
+ # ---------------------------------------------------------------------------
97
+ def _is_missing(v) -> bool:
98
+ if v is None or type(v).__name__ in ("NAType", "NaTType"): # pandas NA/NaT, without pandas
99
+ return True
100
+ try:
101
+ return bool(np.isnan(v))
102
+ except (TypeError, ValueError):
103
+ return False
104
+
105
+
106
+ def _values(col) -> list:
107
+ """A column as a plain Python list (polars/pandas Series, numpy array or any sequence)."""
108
+ for attr in ("to_list", "tolist"):
109
+ if hasattr(col, attr):
110
+ return list(getattr(col, attr)())
111
+ return list(col)
112
+
113
+
114
+ def _column(who, data, key, what):
115
+ """``(values, name)`` for a column given by name (looked up in ``data``) or as an array-like."""
116
+ if key is None:
117
+ return None, None
118
+ if isinstance(key, str):
119
+ if data is None:
120
+ raise TypeError(f"{who}: {what}={key!r} names a column, but no data= was given")
121
+ try:
122
+ col = data[key]
123
+ except Exception as exc: # KeyError (pandas/dict), ColumnNotFoundError (polars), …
124
+ raise KeyError(f"{who}: {what}={key!r} is not a column of data") from exc
125
+ return _values(col), key
126
+ name = getattr(key, "name", None)
127
+ return _values(key), name if isinstance(name, str) else None
128
+
129
+
130
+ def _floats(who, vals, what) -> np.ndarray:
131
+ out = np.empty(len(vals), dtype=float)
132
+ for i, v in enumerate(vals):
133
+ if _is_missing(v):
134
+ out[i] = np.nan
135
+ continue
136
+ try:
137
+ out[i] = float(v)
138
+ except (TypeError, ValueError) as exc:
139
+ raise TypeError(f"{who}: {what} must be numeric; got {v!r}") from exc
140
+ return out
141
+
142
+
143
+ def _plain(v):
144
+ """A JSON-safe scalar for the manifest (numpy scalars → Python, NaN → None, else str)."""
145
+ if _is_missing(v):
146
+ return None
147
+ if hasattr(v, "item"):
148
+ v = v.item()
149
+ return v if isinstance(v, (bool, int, float, str)) else str(v)
150
+
151
+
152
+ def _palette_spec_json(spec):
153
+ """The palette spec as the manifest carries it: a name, or a list of hex colours."""
154
+ if spec is None:
155
+ return None
156
+ if isinstance(spec, (list, tuple)):
157
+ return [_hex(c) for c in spec]
158
+ return str(spec) if isinstance(spec, str) else getattr(spec, "name", str(spec))
159
+
160
+
161
+ def _category_order(xs, order):
162
+ if order is not None:
163
+ return list(order)
164
+ seen = list(dict.fromkeys(v for v in xs if not _is_missing(v)))
165
+ if seen and all(isinstance(v, (int, float, np.number)) and not isinstance(v, bool) for v in seen):
166
+ seen.sort() # numeric categories read in numeric order, like seaborn
167
+ return seen
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # colour: perceptual spaces, light→dark maps, even shades
172
+ # ---------------------------------------------------------------------------
173
+ def _hex(c):
174
+ from matplotlib.colors import to_hex
175
+ return to_hex(c, keep_alpha=False)
176
+
177
+
178
+ def _cut_colour(ax, cut_color):
179
+ """The colour the halo and the median notch are 'cut' with: the axes background by default."""
180
+ if cut_color is not None:
181
+ return cut_color
182
+ for c in (ax.get_facecolor(), ax.figure.get_facecolor()):
183
+ if c[3] > 0:
184
+ return c
185
+ return (1.0, 1.0, 1.0, 1.0)
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # statistics + the median-notch marker
190
+ # ---------------------------------------------------------------------------
191
+ def _stats(vals, interval, center):
192
+ v = vals[np.isfinite(vals)]
193
+ n = int(v.size)
194
+ if n == 0:
195
+ return None
196
+ mean, median = float(v.mean()), float(np.median(v))
197
+ sd = float(v.std(ddof=1)) if n > 1 else float("nan")
198
+ sem = sd / float(np.sqrt(n)) if n > 1 else float("nan")
199
+ q1, q3 = (float(q) for q in np.percentile(v, [25, 75])) # linear, as plt.boxplot / fp.box
200
+ if callable(interval):
201
+ low, high = (float(b) for b in interval(v))
202
+ name = getattr(interval, "__name__", "custom")
203
+ elif interval == "iqr":
204
+ low, high, name = q1, q3, "iqr"
205
+ elif interval == "sem":
206
+ low, high, name = mean - sem, mean + sem, "sem"
207
+ elif interval == "sd":
208
+ low, high, name = mean - sd, mean + sd, "sd"
209
+ else:
210
+ raise ValueError(f"glowbar: interval must be 'iqr', 'sem', 'sd' or a callable; got {interval!r}")
211
+ if center == "auto":
212
+ center = "median" if name == "iqr" else "mean"
213
+ if center not in ("mean", "median"):
214
+ raise ValueError(f"glowbar: center must be 'auto', 'mean' or 'median'; got {center!r}")
215
+ return {"n": n, "mean": mean, "median": median, "sd": sd, "sem": sem, "q1": q1, "q3": q3,
216
+ "interval": name, "low": low, "high": high, "center": center,
217
+ "centerValue": mean if center == "mean" else median}
218
+
219
+
220
+ def _notch_marker(bar_w, depth, height, bleed=0.3):
221
+ """Both V-cuts of the median notch as ONE marker path, in points around the bar's centre line.
222
+
223
+ Returns ``(path, markersize)``; that markersize makes matplotlib's marker scale exactly 1, so the
224
+ path's units stay points. The bases sit ``bleed`` pt outside the bar edges (no anti-alias seam).
225
+ """
226
+ from matplotlib.path import Path
227
+ e, tip, h = bar_w / 2 + bleed, bar_w / 2 - depth, height / 2
228
+ verts = [(-e, -h), (-tip, 0), (-e, h), (-e, -h), (e, -h), (tip, 0), (e, h), (e, -h)]
229
+ codes = [Path.MOVETO, Path.LINETO, Path.LINETO, Path.CLOSEPOLY] * 2
230
+ return Path(verts, codes), 2 * float(np.abs(verts).max())
231
+
232
+
233
+ def _namer(who, override, what):
234
+ if override is None:
235
+ return lambda v: str(v)
236
+ if callable(override):
237
+ return lambda v: str(override(v))
238
+ if isinstance(override, Mapping):
239
+ return lambda v: str(override.get(v, v))
240
+ raise TypeError(f"{who}: {what} must be a mapping or a callable; got {type(override).__name__}")
241
+
242
+
243
+ def _colour_mode(who, point_colors, has_units):
244
+ from matplotlib.colors import to_rgba
245
+ if isinstance(point_colors, Mapping):
246
+ if not has_units:
247
+ raise ValueError(f"{who}: a {{unit: colour}} point_colors mapping needs units=")
248
+ return "mapping"
249
+ if isinstance(point_colors, str) and point_colors in ("auto", "shades", "group"):
250
+ if point_colors == "auto":
251
+ return "shades" if has_units else "group"
252
+ return point_colors
253
+ to_rgba(point_colors) # one colour for every point (raises on garbage)
254
+ return "single"
255
+
256
+
257
+ @dataclass
258
+ class GlowbarResult:
259
+ """What :func:`glowbar` drew — the axes plus everything needed to reuse or annotate it."""
260
+
261
+ ax: Any
262
+ #: category values in plotted (left → right) order; category ``i`` sits at x = ``i``
263
+ categories: list
264
+ #: category → the statistics drawn (``n, mean, median, sd, sem, q1, q3, low, high, center, x``)
265
+ stats: dict
266
+ #: category → its group colour (glow, caps, default mean shade)
267
+ group_colors: dict
268
+ #: unit (or row index without ``units``) → {category: point colour}
269
+ point_colors: dict
270
+ #: category → series name, and unit → series name (the roots of every part id)
271
+ series: dict
272
+ unit_series: dict
273
+ #: part → matplotlib artists (``glow``, ``caps``, ``mean``, ``median``, ``points``, ``lines``)
274
+ artists: dict = field(default_factory=dict)
275
+
276
+ @property
277
+ def positions(self) -> dict:
278
+ """Category name → x (category ``i`` sits at ``i``), as :func:`fluxplot.brackets` wants it."""
279
+ return {str(c): float(i) for i, c in enumerate(self.categories)}
280
+
281
+ def brackets(self, rows, **kw) -> list:
282
+ """Draw ``fp.stats`` post-hoc rows as stacked significance brackets over these
283
+ categories: :func:`fluxplot.brackets` with this plot's ``positions``."""
284
+ from ..brackets import brackets as _brackets
285
+ return _brackets(self.ax, rows, positions=kw.pop("positions", self.positions), **kw)
286
+
287
+
288
+ # ---------------------------------------------------------------------------
289
+ # the scaffold every signature plot shares (fp.glowbar, fp.fluxbox): the table, the unit lanes and
290
+ # colours, the names, the points and connectors — everything but the per-category summary mark
291
+ # ---------------------------------------------------------------------------
292
+ _SIDES = ("outer", "left", "right")
293
+
294
+
295
+ @dataclass
296
+ class _Frame:
297
+ """A table resolved into categories, unit lanes, colours and series names."""
298
+
299
+ who: str # the plot's name, for error messages and the manifest payload key
300
+ xs: list
301
+ ys: np.ndarray
302
+ us: Optional[list]
303
+ y_name: Optional[str]
304
+ units_name: Optional[str]
305
+ cats: list
306
+ pos: dict
307
+ rows_in: dict
308
+ plotted: list
309
+ unit_list: list
310
+ lanes_of: dict
311
+ lane_key: dict
312
+ jitter: float
313
+ group_colors: dict
314
+ point_color: dict # (category, lane key) → colour
315
+ series_of: dict
316
+ unit_series_of: dict
317
+ palette_used: dict = field(default_factory=dict) # category → the colour spec its shades came from
318
+
319
+ def lane_x(self, c, key):
320
+ lanes = self.lanes_of[c]
321
+ spread = np.linspace(-1.0, 1.0, len(lanes)) if len(lanes) > 1 else np.zeros(1)
322
+ return self.pos[c] + self.jitter * float(spread[lanes.index(key)])
323
+
324
+ def summary_x(self, k, side, offset):
325
+ """x of category ``k``'s summary mark, ``offset`` to the ``side`` of its points."""
326
+ if side == "outer": # the first category's summary to the left, every other to the right
327
+ sign = -1 if (k == 0 and len(self.cats) > 1) else 1
328
+ else:
329
+ sign = -1 if side == "left" else 1
330
+ return float(self.pos[self.cats[k]] + sign * offset)
331
+
332
+ def point_colors_by_unit(self):
333
+ out: dict = {}
334
+ for (c, key), col in self.point_color.items():
335
+ out.setdefault(key, {})[c] = col
336
+ return out
337
+
338
+
339
+ def _frame(who, data, x, y, units, order, unit_order, *, jitter, palette, group_color,
340
+ group_color_position, point_colors, shade_range, interleave_shades, series, unit_series,
341
+ connect, point_fill_alpha, ground=None) -> _Frame:
342
+ from matplotlib.colors import to_rgba
343
+
344
+ xs, _ = _column(who, data, x, "x")
345
+ yv, y_name = _column(who, data, y, "y")
346
+ us, units_name = _column(who, data, units, "units")
347
+ if len(yv) != len(xs) or (us is not None and len(us) != len(xs)):
348
+ raise ValueError(f"{who}: x, y and units must have the same length")
349
+ ys = _floats(who, yv, "y")
350
+ if connect and us is None:
351
+ raise ValueError(f"{who}: connect_identical_points_across_x_values needs units= "
352
+ "(which points are 'the same' is a unit's identity)")
353
+ colour_mode = _colour_mode(who, point_colors, us is not None)
354
+ if not 0.0 <= point_fill_alpha <= 1.0:
355
+ raise ValueError(f"{who}: point_fill_alpha must be in [0, 1]; got {point_fill_alpha!r}")
356
+
357
+ cats = _category_order(xs, order)
358
+ pos = {c: i for i, c in enumerate(cats)}
359
+ rows_in = {c: [i for i, v in enumerate(xs) if not _is_missing(v) and v == c] for c in cats}
360
+ plotted = sorted(i for c in cats for i in rows_in[c])
361
+
362
+ # ---- identity: every unit gets a fixed lane per category, from the table — never the values ----
363
+ if us is not None:
364
+ unit_list = (list(unit_order) if unit_order is not None else
365
+ list(dict.fromkeys(us[i] for i in plotted if not _is_missing(us[i]))))
366
+ lanes_of = {c: [u for u in unit_list if any(us[i] == u for i in rows_in[c])] for c in cats}
367
+ lane_key = {i: us[i] for i in plotted}
368
+ else:
369
+ unit_list = []
370
+ lanes_of = {c: list(rows_in[c]) for c in cats} # one lane per row, in table order
371
+ lane_key = {i: i for i in plotted}
372
+
373
+ # ---- names (the recipe's per-series colours are keyed by the series id) ------------------------
374
+ cat_name, unit_name = _namer(who, series, "series"), _namer(who, unit_series, "unit_series")
375
+
376
+ # ---- colours ------------------------------------------------------------------------------------
377
+ from .. import style as _style
378
+ from ..api import _series_color_override
379
+ from ..colors import categories as _categories
380
+
381
+ recipe_palette = _style.palette_override() # a categorical palette Flux asked for on a rerun
382
+
383
+ def palette_spec(c, k):
384
+ default = DEFAULT_MAPS[k % len(DEFAULT_MAPS)]
385
+ if palette is None:
386
+ if _categories.is_pinned(c):
387
+ return _categories.get(c) # a pinned category: a ramp of its own colour
388
+ return _categories.get(c, palette=recipe_palette) if recipe_palette else default
389
+ if isinstance(palette, Mapping):
390
+ return palette.get(c, palette.get(str(c), default))
391
+ if isinstance(palette, (list, tuple)):
392
+ return palette[k % len(palette)]
393
+ return palette
394
+
395
+ sources, group_colors, palette_used = {}, {}, {}
396
+ for k, c in enumerate(cats):
397
+ spec = palette_spec(c, k)
398
+ src = _colour.resolve(spec, f"{who} {c}")
399
+ sources[c] = src
400
+ palette_used[c] = spec
401
+ override = group_color.get(c, group_color.get(str(c))) if isinstance(group_color, Mapping) \
402
+ else group_color
403
+ if override is None:
404
+ # a recipe's per-series colour, else a category pinned in fp.colors.categories, else the source
405
+ override = _series_color_override(cat_name(c))
406
+ if override is None and _categories.is_pinned(c):
407
+ override = _categories.get(c)
408
+ group_colors[c] = to_rgba(override) if override is not None else \
409
+ _colour.representative(src, group_color_position, ground=ground)
410
+
411
+ point_color = {} # (category, lane key) → colour
412
+ bounds = _colour.shade_bounds(*shade_range, ground=ground) # lifted off a dark ground
413
+ for c in cats:
414
+ lanes = lanes_of[c]
415
+ if colour_mode == "shades":
416
+ shades = _colour.shades(sources[c], len(lanes), *bounds)
417
+ # interleaving spreads an ORDERED run of shades; a qualitative palette is already distinct
418
+ spread = interleave_shades and (sources[c].ordered or sources[c].kind == "continuous")
419
+ ranks = interleaved_order(len(lanes)) if spread else range(len(lanes))
420
+ point_color.update({(c, key): shades[r] for key, r in zip(lanes, ranks)})
421
+ elif colour_mode == "group":
422
+ point_color.update({(c, key): group_colors[c] for key in lanes})
423
+ elif colour_mode == "single":
424
+ point_color.update({(c, key): to_rgba(point_colors) for key in lanes})
425
+ else: # mapping
426
+ point_color.update({(c, key): to_rgba(point_colors.get(key, group_colors[c])) for key in lanes})
427
+
428
+ # ---- names --------------------------------------------------------------------------------------
429
+ series_of = {c: cat_name(c) for c in cats}
430
+ unit_series_of = {u: unit_name(u) for u in unit_list}
431
+ clash = ({_ids.series_root(s) for s in series_of.values()}
432
+ & {_ids.series_root(s) for s in unit_series_of.values()})
433
+ if clash:
434
+ raise ValueError(f"{who}: a category and a unit would share the series id(s) {sorted(clash)}; "
435
+ "pass series= or unit_series= to rename one of them")
436
+
437
+ return _Frame(who=who, xs=xs, ys=ys, us=us, y_name=y_name, units_name=units_name, cats=cats,
438
+ pos=pos, rows_in=rows_in, plotted=plotted, unit_list=unit_list, lanes_of=lanes_of,
439
+ lane_key=lane_key, jitter=jitter, group_colors=group_colors,
440
+ point_color=point_color, series_of=series_of, unit_series_of=unit_series_of,
441
+ palette_used=palette_used)
442
+
443
+
444
+ def _draw_units(fr: _Frame, ax, reg, *, connect, connect_line_width, connect_color, connect_alpha,
445
+ show_points, point_size, point_edge, point_edge_width, point_fill_alpha, zorder,
446
+ ground=None):
447
+ """The connectors (just under ``zorder``) and the individual points (at it) → ``(lines, points)``."""
448
+ from matplotlib.colors import to_rgba
449
+
450
+ xs, ys, us = fr.xs, fr.ys, fr.us
451
+ lines, points = [], []
452
+ connect_color = _connect_colour(connect_color, ground)
453
+ edge = (lambda col: _colour.rim(col, ground)) if point_edge == "rim" else (lambda col: point_edge)
454
+
455
+ if connect:
456
+ for u in fr.unit_list:
457
+ px, py = [], []
458
+ for c in fr.cats:
459
+ vals = [ys[i] for i in fr.rows_in[c] if us[i] == u and np.isfinite(ys[i])]
460
+ px.append(fr.lane_x(c, u) if vals else np.nan)
461
+ py.append(float(np.mean(vals)) if vals else np.nan)
462
+ finite = np.isfinite(py)
463
+ if not np.any(finite[:-1] & finite[1:]):
464
+ continue # nothing adjacent to join
465
+ first = next(c for c, f in zip(fr.cats, finite) if f)
466
+ colour = fr.point_color[(first, u)] if connect_color == "unit" else connect_color
467
+ (ln,) = ax.plot(px, py, color=colour, alpha=connect_alpha, lw=connect_line_width,
468
+ solid_capstyle="round", zorder=zorder - 0.5)
469
+ reg.add(Mark(role="line", series=fr.unit_series_of[u], kind="line", live_data=True,
470
+ artists=[ln]))
471
+ lines.append(ln)
472
+
473
+ if show_points:
474
+ if us is not None: # one series per unit: the same animal is the same part in every plot
475
+ groups = [(fr.unit_series_of[u], [i for i in fr.plotted if us[i] == u], u)
476
+ for u in fr.unit_list]
477
+ else:
478
+ groups = [(fr.series_of[c], fr.rows_in[c], None) for c in fr.cats]
479
+ for name, idx, u in groups:
480
+ keep = [i for i in idx if np.isfinite(ys[i])]
481
+ if not keep:
482
+ continue
483
+ cols = [fr.point_color[(xs[i], fr.lane_key[i])] for i in keep]
484
+ coll = ax.scatter([fr.lane_x(xs[i], fr.lane_key[i]) for i in keep],
485
+ [float(ys[i]) for i in keep],
486
+ s=point_size, edgecolors=[edge(col) for col in cols],
487
+ facecolors=[(*to_rgba(col)[:3], to_rgba(col)[3] * point_fill_alpha)
488
+ for col in cols],
489
+ linewidths=point_edge_width, zorder=zorder)
490
+ payload = {}
491
+ if u is not None:
492
+ payload = {fr.who: {
493
+ "part": "unit", "units": fr.units_name, "unit": _plain(u),
494
+ "categories": [_plain(xs[i]) for i in keep],
495
+ "colors": [_hex(col) for col in cols]}}
496
+ reg.add(Mark(role="point", series=name, kind="scatter", live_data=True, artists=[coll],
497
+ indexed=True, data=payload))
498
+ points.append(coll)
499
+ return lines, points
500
+
501
+
502
+ def _finish(fr: _Frame, ax, label_axes):
503
+ if label_axes:
504
+ ax.set_xticks(range(len(fr.cats)), [str(c) for c in fr.cats])
505
+ ax.set_xlim(-0.75, len(fr.cats) - 0.25)
506
+ ax.margins(y=0.08)
507
+ ax.tick_params(axis="x", length=0)
508
+ if fr.y_name:
509
+ ax.set_ylabel(fr.y_name)
510
+ ax.autoscale_view()
511
+
512
+
513
+ # ---------------------------------------------------------------------------
514
+ # the plot
515
+ # ---------------------------------------------------------------------------
516
+ def glowbar(
517
+ data=None,
518
+ *,
519
+ x,
520
+ y,
521
+ units=None,
522
+ order: Optional[Sequence] = None,
523
+ unit_order: Optional[Sequence] = None,
524
+ ax=None,
525
+ # the summary bar
526
+ interval: Union[str, Callable] = "sem",
527
+ center: str = "auto",
528
+ show_mean: bool = True,
529
+ show_median: bool = True,
530
+ show_caps: bool = True,
531
+ bar_width: float = 5.0,
532
+ bar_offset: float = 0.42,
533
+ bar_side: str = "outer",
534
+ glow_steps: int = 28,
535
+ glow_alpha: float = 0.06,
536
+ mean_line_width: float = 1.2,
537
+ mean_color=None,
538
+ mean_halo_width: float = 1.5,
539
+ median_notch_depth: float = 1.3,
540
+ median_notch_height: float = 2.2,
541
+ cap_width: float = 0.8,
542
+ cap_color=None,
543
+ cut_color=None,
544
+ # the individual points
545
+ show_individual_points: bool = True,
546
+ point_size: float = 18.0,
547
+ jitter: float = 0.14,
548
+ point_edge="rim",
549
+ point_edge_width: float = 0.5,
550
+ point_fill_alpha: float = 1.0,
551
+ # colour
552
+ palette=None,
553
+ group_color_position: Optional[float] = None,
554
+ group_color=None,
555
+ point_colors="auto",
556
+ shade_range: tuple = (88.0, 22.0),
557
+ interleave_shades: bool = True,
558
+ # paired / repeated measures
559
+ connect_identical_points_across_x_values: bool = False,
560
+ connect_line_width: float = 0.6,
561
+ connect_color=None,
562
+ connect_alpha: float = 0.8,
563
+ # identity (the sidecar names)
564
+ series=None,
565
+ unit_series=None,
566
+ label_axes: bool = True,
567
+ zorder: float = 2.0,
568
+ ) -> GlowbarResult:
569
+ """Draw a glowbar: the individual points plus a glowing interval bar with mean line and median notch.
570
+
571
+ Parameters
572
+ ----------
573
+ data
574
+ A pandas or polars DataFrame, a dict of columns, or ``None`` when ``x``/``y``/``units`` are
575
+ passed as arrays.
576
+ x, y
577
+ Column names (or array-likes): ``x`` is categorical (one bar per value), ``y`` numeric. Rows
578
+ whose ``x`` or ``y`` is missing are not drawn.
579
+ units
580
+ Optional identity column (subject, animal, cell…). Each unit gets a fixed lane and colour
581
+ inside every category, derived from the table order (``unit_order``), never from the values
582
+ — so separate plots of different measures from the same table agree, even when a unit is
583
+ missing a value in one of them. Units are also what
584
+ ``connect_identical_points_across_x_values`` joins.
585
+ order, unit_order
586
+ Category order (left → right) and unit order (first lane → last). Default: first appearance
587
+ in the data (numeric categories are sorted). Categories missing from ``order`` are dropped.
588
+ ax
589
+ Target axes (default: the current axes).
590
+
591
+ interval
592
+ What the glowing bar and its caps span: ``"sem"`` (mean ± SEM — default), ``"iqr"`` (Q1–Q3,
593
+ the box of a box plot), ``"sd"`` (mean ± SD), or a callable ``values -> (low, high)``.
594
+ center
595
+ Where the glow is densest: ``"auto"`` (the median for ``"iqr"``, else the mean), ``"mean"``
596
+ or ``"median"``. The glow fades from there toward each cap independently, so a skewed group
597
+ glows asymmetrically.
598
+ show_mean, show_median, show_caps
599
+ Toggle the mean line, the median notch and the interval caps.
600
+ bar_width
601
+ Bar width in points; the mean line and the caps span exactly this width.
602
+ bar_offset, bar_side
603
+ Distance (category units) of the bar from its category's centre, and its side: ``"outer"``
604
+ (the first category's bar to the left, every other to the right, so the bars frame the
605
+ comparison — default), ``"left"`` or ``"right"``.
606
+ glow_steps, glow_alpha
607
+ The glow is ``glow_steps`` nested segments of opacity ``glow_alpha``; their overlap builds the
608
+ gradient (peak opacity ≈ ``1 - (1 - glow_alpha) ** glow_steps``).
609
+ mean_line_width, mean_color, mean_halo_width
610
+ Stroke of the mean line (points), its colour (default: the group colour deepened — or, on a
611
+ dark ground, lifted — just far enough to stand off the glow) and the width of the
612
+ background-coloured halo that lifts it off the glow (``0`` disables it).
613
+ median_notch_depth, median_notch_height
614
+ How far each V-cut reaches into the bar, and its height, in points. The notch is drawn above
615
+ the mean line, so it stays visible when the median meets the mean.
616
+ cap_width, cap_color
617
+ Stroke and colour (default: the group colour) of the interval caps.
618
+ cut_color
619
+ Colour of the notch and halo "cuts" (default: the axes background).
620
+
621
+ show_individual_points
622
+ Draw the individual observations (default ``True``).
623
+ point_size, jitter
624
+ Marker area (points²), and the half-width of the lane spread (category units). Lanes are
625
+ evenly spaced, never random.
626
+ point_edge, point_edge_width
627
+ ``"rim"`` (a deeper shade of each point's own colour, which keeps pale points crisp —
628
+ default), ``"none"`` or any colour; and the edge width in points.
629
+ point_fill_alpha
630
+ Opacity of the points' fill only (default ``1.0``). The rim / edge keeps full opacity, so
631
+ translucent points stay crisply outlined where they overlap.
632
+
633
+ palette
634
+ Per-category colour source, given once for every category, as a list (cycled in category
635
+ order) or as a ``{category: spec}`` mapping. A spec can be anything in fluxplot's colour
636
+ library — a colormap from ``fp.colors.maps`` (``"cmasher.emerald"``, ``"crameri.batlow"``,
637
+ ``"tol.sunset"``, a bare ``"YlGnBu"``, ``_r`` reversed), a palette from
638
+ ``fp.colors.palettes`` (``"brewer.Set2"``, ``"tol.bright"``, ``"flexoki.blue"``), any
639
+ matplotlib colormap or ``Colormap``, a list of colours, or a single colour. The glowbar
640
+ picks as many distinct point colours as each group needs plus one solid group colour:
641
+ sequential sources give equal perceptual steps (oriented light → dark, whatever their
642
+ source direction); diverging / cyclic maps are sampled evenly along their usable (not too
643
+ pale) stretches; qualitative palettes keep their own order. Default: the ColorBrewer
644
+ maps ``YlGnBu``, ``YlOrRd``, ``RdPu``, ``BuGn``, ``Purples``, ``YlOrBr``. (Because a palette list
645
+ like ``["#123", "#456"]`` means one spec per category, wrap a hand-made palette for a
646
+ single category in a mapping: ``{"SD": ["#123", "#456", "#789"]}``.)
647
+ group_color_position
648
+ Pin the group colour to a point of an ordered (sequential) source's light → dark ramp
649
+ (0 = palest, 1 = darkest). Default ``None`` chooses: 0.75 along a ColorBrewer ramp; for any
650
+ other source its most chromatic mid-lightness colour when its hues agree (e.g. cmasher
651
+ ``emerald``), or a neutral ink when they spread around the wheel (diverging, rainbow,
652
+ qualitative) — no single hue honestly stands for those.
653
+ group_color
654
+ Override the group colour (glow, caps, mean shade) outright: one colour, or a
655
+ ``{category: colour}`` mapping. Point colours still come from ``palette``.
656
+ point_colors
657
+ ``"shades"`` (each unit its own shade of its category's map), ``"group"`` (every point in its
658
+ group colour), ``"auto"`` (shades with ``units``, else group — default), one colour for all
659
+ points, or a ``{unit: colour}`` mapping.
660
+ shade_range, interleave_shades
661
+ Lightness of the palest and darkest shade (0 = black, 100 = white), spaced in equal
662
+ perceptual steps; and whether shades are dealt across lanes so neighbours always contrast
663
+ (default ``True``) or run pale → dark in lane order. On a dark ground the dark bound is
664
+ lifted to stay at least 20 lightness units above the ground.
665
+
666
+ connect_identical_points_across_x_values
667
+ Join each unit's points across the x categories with a line (needs ``units``) — for paired
668
+ or repeated-measures designs. A unit missing from a category breaks its line there instead
669
+ of bridging the gap; several rows of one unit in one category are joined through their mean.
670
+ connect_line_width, connect_color, connect_alpha
671
+ Connector stroke (points), colour (a colour, or ``"unit"`` for each unit's own point colour)
672
+ and opacity. The default is a quiet neutral — the active theme's grid colour, else Flexoki
673
+ base-300 — so the lines never compete with the points.
674
+
675
+ series, unit_series
676
+ Override the series names — the roots of every part id — per category / per unit, as a
677
+ mapping or a callable. Defaults: the category and unit values (``"SD"`` → ``sd.glow``,
678
+ ``"B6_8"`` → ``b6-8.points``).
679
+ label_axes
680
+ Put the category names on the x ticks and the ``y`` column name on the y axis, and set the x
681
+ limits (default ``True``).
682
+ zorder
683
+ Base z-order: connectors sit just below it, the points at it, the bar above.
684
+
685
+ Returns
686
+ -------
687
+ GlowbarResult
688
+ The axes, category order, statistics, colours, series names and artists.
689
+ """
690
+ import matplotlib.patheffects as pe
691
+ import matplotlib.pyplot as plt
692
+ from matplotlib.collections import LineCollection
693
+ from matplotlib.colors import to_rgba
694
+
695
+ if ax is None:
696
+ ax = plt.gca()
697
+ if bar_side not in _SIDES:
698
+ raise ValueError(f"glowbar: bar_side must be 'outer', 'left' or 'right'; got {bar_side!r}")
699
+ cut = _cut_colour(ax, cut_color) # the ground every ink is judged against
700
+ fr = _frame("glowbar", data, x, y, units, order, unit_order, jitter=jitter, palette=palette,
701
+ group_color=group_color, group_color_position=group_color_position,
702
+ point_colors=point_colors, shade_range=shade_range,
703
+ interleave_shades=interleave_shades, series=series, unit_series=unit_series,
704
+ connect=connect_identical_points_across_x_values, point_fill_alpha=point_fill_alpha,
705
+ ground=cut)
706
+ reg = _tagger.registry_for(ax.figure)
707
+ artists = {"glow": [], "caps": [], "mean": [], "median": [], "points": [], "lines": []}
708
+ artists["lines"], artists["points"] = _draw_units(
709
+ fr, ax, reg, connect=connect_identical_points_across_x_values,
710
+ connect_line_width=connect_line_width, connect_color=connect_color,
711
+ connect_alpha=connect_alpha, show_points=show_individual_points, point_size=point_size,
712
+ point_edge=point_edge, point_edge_width=point_edge_width,
713
+ point_fill_alpha=point_fill_alpha, zorder=zorder, ground=cut)
714
+
715
+ # ---- the glowing bar -------------------------------------------------------------------------------
716
+ notch, notch_ms = _notch_marker(bar_width, median_notch_depth, median_notch_height)
717
+ stats = {}
718
+ for k, c in enumerate(fr.cats):
719
+ st = _stats(fr.ys[fr.rows_in[c]], interval, center)
720
+ if st is None:
721
+ continue
722
+ s, col = fr.series_of[c], fr.group_colors[c]
723
+ bx = fr.summary_x(k, bar_side, bar_offset)
724
+ st["x"] = bx
725
+ stats[c] = dict(st, groupColor=col)
726
+ lo, hi = st["low"], st["high"]
727
+ drawable = bool(np.isfinite(lo) and np.isfinite(hi) and hi >= lo)
728
+ # the exact statistics drawn ride on the category's first summary part → the manifest
729
+ payload = {"glowbar": {"part": "summary", "category": _plain(c), "units": fr.units_name,
730
+ "groupColor": _hex(col), "palette": _palette_spec_json(fr.palette_used.get(c)),
731
+ **{key: _plain(v) for key, v in st.items()}}}
732
+ if drawable:
733
+ mid = min(max(st["centerValue"], lo), hi) # glow centre, clamped into the interval
734
+ t = np.linspace(1.0, 0.0, glow_steps, endpoint=False) # nested: overlap builds the gradient
735
+ glow = LineCollection([[(bx, mid - (mid - lo) * ti), (bx, mid + (hi - mid) * ti)] for ti in t],
736
+ colors=[(*to_rgba(col)[:3], glow_alpha)], linewidths=bar_width,
737
+ capstyle="butt", zorder=zorder + 0.5)
738
+ ax.add_collection(glow)
739
+ reg.add(Mark(role="box", series=s, name="glow", kind="glowbar", artists=[glow], data=payload))
740
+ artists["glow"].append(glow)
741
+ payload = {}
742
+ if show_caps:
743
+ (caps,) = ax.plot([bx, bx], [lo, hi], ls="none", marker="_", ms=bar_width, mew=cap_width,
744
+ color=cap_color if cap_color is not None else col, zorder=zorder + 0.6)
745
+ reg.add(Mark(role="cap", series=s, name="caps", kind="glowbar", artists=[caps]))
746
+ artists["caps"].append(caps)
747
+ if show_mean:
748
+ # marker '_' spans exactly ms points: the mean line is as wide as the bar
749
+ effects = ([pe.Stroke(linewidth=mean_halo_width, foreground=cut), pe.Normal()]
750
+ if mean_halo_width else None)
751
+ # the mean's default ink stands 30 lightness units off the glow's peak over the ground:
752
+ # deepened on a light ground, lifted on a dark one (the fluxbox's median rule)
753
+ peak = 1.0 - (1.0 - glow_alpha) ** glow_steps
754
+ (mean_ln,) = ax.plot([bx], [st["mean"]], marker="_", ms=bar_width, mew=mean_line_width,
755
+ color=mean_color if mean_color is not None else _colour.median_ink(col, peak, cut, 30.0),
756
+ zorder=zorder + 1.5, path_effects=effects)
757
+ reg.add(Mark(role="mean", series=s, kind="glowbar", artists=[mean_ln], data=payload))
758
+ artists["mean"].append(mean_ln)
759
+ payload = {}
760
+ if show_median and drawable:
761
+ (med,) = ax.plot([bx], [st["median"]], ls="none", marker=notch, ms=notch_ms, mfc=cut,
762
+ mec="none", mew=0, zorder=zorder + 2)
763
+ reg.add(Mark(role="median", series=s, kind="glowbar", artists=[med], data=payload))
764
+ artists["median"].append(med)
765
+
766
+ _finish(fr, ax, label_axes)
767
+ return GlowbarResult(ax=ax, categories=fr.cats, stats=stats, group_colors=fr.group_colors,
768
+ point_colors=fr.point_colors_by_unit(), series=fr.series_of,
769
+ unit_series=fr.unit_series_of, artists=artists)