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.
- fluxplot/__init__.py +115 -0
- fluxplot/_fieldmap.py +97 -0
- fluxplot/_mesh_reduce.py +54 -0
- fluxplot/_scene3d_size.py +95 -0
- fluxplot/_viewer/THIRD-PARTY.txt +23 -0
- fluxplot/_viewer/flux-model3d-viewer.min.js +4221 -0
- fluxplot/_viewer/stamp.json +4 -0
- fluxplot/api.py +1196 -0
- fluxplot/autotag.py +164 -0
- fluxplot/base.mplstyle +0 -0
- fluxplot/brackets.py +242 -0
- fluxplot/canonical_json.py +23 -0
- fluxplot/capture.py +150 -0
- fluxplot/colorcheck.py +285 -0
- fluxplot/colors.py +727 -0
- fluxplot/colorscale.py +477 -0
- fluxplot/data.py +178 -0
- fluxplot/definitions/colormaps.json +1639 -0
- fluxplot/definitions/flexoki.tokens.json +2571 -0
- fluxplot/definitions/palettes.json +2547 -0
- fluxplot/descriptors.py +87 -0
- fluxplot/fields.py +611 -0
- fluxplot/fits.py +240 -0
- fluxplot/glb.py +84 -0
- fluxplot/ids.py +173 -0
- fluxplot/images.py +362 -0
- fluxplot/integrity.py +27 -0
- fluxplot/manifest.py +788 -0
- fluxplot/mesh3d.py +376 -0
- fluxplot/panels.py +284 -0
- fluxplot/postprocess.py +638 -0
- fluxplot/presets.py +66 -0
- fluxplot/provenance.py +177 -0
- fluxplot/raster.py +295 -0
- fluxplot/recipe.py +178 -0
- fluxplot/render.py +66 -0
- fluxplot/roles.py +147 -0
- fluxplot/scene3d.py +386 -0
- fluxplot/scene3d_manifest.py +112 -0
- fluxplot/scene3d_viewer.py +633 -0
- fluxplot/schemas/.gitkeep +0 -0
- fluxplot/schemas/manifest.schema.json +2479 -0
- fluxplot/schemas/recipe.schema.json +179 -0
- fluxplot/schemas/scene3d.schema.json +461 -0
- fluxplot/seaborn_adapters.py +323 -0
- fluxplot/signature_fluxplots/__init__.py +18 -0
- fluxplot/signature_fluxplots/_colour.py +412 -0
- fluxplot/signature_fluxplots/fluxbox.py +433 -0
- fluxplot/signature_fluxplots/glowbar.py +769 -0
- fluxplot/signature_fluxplots/hexmatrix.py +927 -0
- fluxplot/stats/__init__.py +63 -0
- fluxplot/stats/_common.py +196 -0
- fluxplot/stats/multi_group.py +443 -0
- fluxplot/stats/paired.py +209 -0
- fluxplot/stats/two_group.py +149 -0
- fluxplot/style.py +469 -0
- fluxplot/surface.py +487 -0
- fluxplot/surface3d.py +197 -0
- fluxplot/tagger.py +561 -0
- fluxplot/version.py +19 -0
- fluxplot-0.1.0.dist-info/METADATA +1199 -0
- fluxplot-0.1.0.dist-info/RECORD +65 -0
- fluxplot-0.1.0.dist-info/WHEEL +4 -0
- fluxplot-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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)
|