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,433 @@
1
+ """``fp.fluxbox`` — the box plot, drawn the FluxPlot way.
2
+
3
+ A fluxbox is a glowbar whose summary mark is a box plot. Every observation is a dot and, beside each
4
+ group, sits a slim box; everything else — the fixed per-unit lanes and colours, the paired
5
+ connectors, the names — is the glowbar's own machinery, so a fluxbox and a glowbar of the same table
6
+ agree dot for dot and can be swapped for one another. Four statistics are read straight off the box:
7
+
8
+ * the **interquartile range** (Q1–Q3) — the box, a half-strength wash of the group colour;
9
+ * the **median** — a solid line across the box in the group's own hue, deepened (on a dark
10
+ background: lifted) just as far as it takes to stand clearly off the box, so it reads for any
11
+ palette and any theme. That colour is the box plot's one *ink*: the whiskers (and fliers) share it;
12
+ * the **mean** — a V-notch cut into both edges of the box (the glowbar's median notch). A mean that
13
+ falls outside the box (a strongly skewed group) keeps its mark: the same two V's, drawn solid in
14
+ the ink, pointing in at the whisker;
15
+ * the **whiskers** — plain capless lines in the median's colour, reaching the most extreme
16
+ observations within ``whis`` × IQR of the box (Tukey's rule — exactly ``plt.boxplot``'s
17
+ whiskers), the full range, or a pair of percentiles.
18
+
19
+ Observations beyond the whiskers are not drawn a second time as fliers — the individual points
20
+ already show them. With ``show_individual_points=False`` they are (``show_fliers="auto"``).
21
+
22
+ Everything drawn is a named part, so a fluxbox round-trips through Flux like any other FluxPlot:
23
+
24
+ ======================= =============================================== ===========
25
+ part default id role
26
+ ======================= =============================================== ===========
27
+ box (Q1–Q3) ``<category>.box`` ``box``
28
+ whiskers ``<category>.whiskers`` ``whisker``
29
+ whisker caps (opt-in) ``<category>.caps`` ``cap``
30
+ median line ``<category>.median`` ``median``
31
+ mean notch ``<category>.mean`` ``mean``
32
+ fliers (points hidden) ``<category>.fliers`` ``flier``
33
+ a unit's point(s) ``<unit>.points``, ``<unit>.point.<k>`` ``point``
34
+ a unit's connector ``<unit>.line`` ``line``
35
+ points (no ``units``) ``<category>.points``, ``<category>.point.<k>`` ``point``
36
+ ======================= =============================================== ===========
37
+
38
+ Each category series carries a ``fluxbox`` payload in the manifest with the exact statistics drawn
39
+ (n, mean, median, sd, sem, q1, q3, iqr, the whisker rule and ends, the outliers, the group colour),
40
+ and each unit series carries its identity, exactly as for the glowbar.
41
+
42
+ Example
43
+ -------
44
+ >>> import fluxplot as fp, matplotlib.pyplot as plt
45
+ >>> fig, ax = plt.subplots(figsize=(1.6, 1.8))
46
+ >>> fb = fp.fluxbox(df, x="condition", y="APP/GAPDH", units="subject", ax=ax)
47
+ >>> fb.stats["SD"]["whiskerHigh"]
48
+ 3.47
49
+ >>> fp.save(fig, "plots/app_gapdh.svg")
50
+ """
51
+ from __future__ import annotations
52
+
53
+ from dataclasses import dataclass, field
54
+ from numbers import Real
55
+ from typing import Any, Optional, Sequence, Union
56
+
57
+ import numpy as np
58
+
59
+ from .. import tagger as _tagger
60
+ from ..descriptors import Mark
61
+ from ._colour import median_ink as _median_ink
62
+ from .glowbar import (
63
+ _SIDES,
64
+ _cut_colour,
65
+ _draw_units,
66
+ _finish,
67
+ _frame,
68
+ _hex,
69
+ _notch_marker,
70
+ _palette_spec_json,
71
+ _plain,
72
+ )
73
+
74
+ __all__ = ["fluxbox", "FluxboxResult"]
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # statistics
78
+ # ---------------------------------------------------------------------------
79
+ def _whisker_rule(whis):
80
+ """Validate ``whis`` into its manifest form: a factor of the IQR, ``"range"`` or ``[lo, hi]``."""
81
+ def number(v):
82
+ return isinstance(v, Real) and not isinstance(v, bool)
83
+
84
+ if isinstance(whis, str) and whis == "range" or (number(whis) and np.isinf(whis) and whis > 0):
85
+ return "range"
86
+ if number(whis) and whis >= 0:
87
+ return float(whis)
88
+ if isinstance(whis, (list, tuple, np.ndarray)) and len(whis) == 2 and all(map(number, whis)):
89
+ lo, hi = float(whis[0]), float(whis[1])
90
+ if 0.0 <= lo <= hi <= 100.0:
91
+ return [lo, hi]
92
+ raise ValueError("fluxbox: whis must be a non-negative factor of the IQR, 'range', or a "
93
+ f"(low, high) pair of percentiles; got {whis!r}")
94
+
95
+
96
+ def _box_stats(vals, rule):
97
+ """One group's box-plot statistics. Quartiles and whiskers follow ``plt.boxplot`` exactly."""
98
+ v = vals[np.isfinite(vals)]
99
+ n = int(v.size)
100
+ if n == 0:
101
+ return None
102
+ mean, median = float(v.mean()), float(np.median(v))
103
+ sd = float(v.std(ddof=1)) if n > 1 else float("nan")
104
+ sem = sd / float(np.sqrt(n)) if n > 1 else float("nan")
105
+ q1, q3 = (float(q) for q in np.percentile(v, [25, 75]))
106
+ if rule == "range":
107
+ reach_lo, reach_hi = -np.inf, np.inf
108
+ elif isinstance(rule, list):
109
+ reach_lo, reach_hi = np.percentile(v, rule)
110
+ else:
111
+ reach_lo, reach_hi = q1 - rule * (q3 - q1), q3 + rule * (q3 - q1)
112
+ # a whisker ends ON an observation — the most extreme one within reach — and never inside the box
113
+ below, above = v[v >= reach_lo], v[v <= reach_hi]
114
+ w_lo = float(below.min()) if below.size and below.min() <= q1 else q1
115
+ w_hi = float(above.max()) if above.size and above.max() >= q3 else q3
116
+ outliers = sorted(float(o) for o in v[(v < w_lo) | (v > w_hi)])
117
+ return {"n": n, "mean": mean, "median": median, "sd": sd, "sem": sem, "q1": q1, "q3": q3,
118
+ "iqr": q3 - q1, "whis": rule, "whiskerLow": w_lo, "whiskerHigh": w_hi,
119
+ "outliers": outliers}
120
+
121
+
122
+ def _manifest_value(v):
123
+ return [_plain(e) for e in v] if isinstance(v, list) else _plain(v)
124
+
125
+
126
+ @dataclass
127
+ class FluxboxResult:
128
+ """What :func:`fluxbox` drew — the axes plus everything needed to reuse or annotate it."""
129
+
130
+ ax: Any
131
+ #: category values in plotted (left → right) order; category ``i`` sits at x = ``i``
132
+ categories: list
133
+ #: category → the statistics drawn (``n, mean, median, sd, sem, q1, q3, iqr, whis, whiskerLow,
134
+ #: whiskerHigh, outliers, x``)
135
+ stats: dict
136
+ #: category → its group colour (the box; the median / whisker ink is derived from it)
137
+ group_colors: dict
138
+ #: unit (or row index without ``units``) → {category: point colour}
139
+ point_colors: dict
140
+ #: category → series name, and unit → series name (the roots of every part id)
141
+ series: dict
142
+ unit_series: dict
143
+ #: part → matplotlib artists (``box``, ``whiskers``, ``caps``, ``median``, ``mean``, ``fliers``,
144
+ #: ``points``, ``lines``)
145
+ artists: dict = field(default_factory=dict)
146
+
147
+ @property
148
+ def positions(self) -> dict:
149
+ """Category name → x (category ``i`` sits at ``i``), as :func:`fluxplot.brackets` wants it."""
150
+ return {str(c): float(i) for i, c in enumerate(self.categories)}
151
+
152
+ def brackets(self, rows, **kw) -> list:
153
+ """Draw ``fp.stats`` post-hoc rows as stacked significance brackets over these
154
+ categories: :func:`fluxplot.brackets` with this plot's ``positions``."""
155
+ from ..brackets import brackets as _brackets
156
+ return _brackets(self.ax, rows, positions=kw.pop("positions", self.positions), **kw)
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # the plot
161
+ # ---------------------------------------------------------------------------
162
+ def fluxbox(
163
+ data=None,
164
+ *,
165
+ x,
166
+ y,
167
+ units=None,
168
+ order: Optional[Sequence] = None,
169
+ unit_order: Optional[Sequence] = None,
170
+ ax=None,
171
+ # the box
172
+ whis: Union[float, str, Sequence[float]] = 1.5,
173
+ show_mean: bool = True,
174
+ show_median: bool = True,
175
+ show_whiskers: bool = True,
176
+ show_caps: bool = False,
177
+ show_fliers: Union[bool, str] = "auto",
178
+ box_width: float = 7.0,
179
+ box_alpha: float = 0.5,
180
+ box_offset: float = 0.42,
181
+ box_side: str = "outer",
182
+ median_line_width: float = 1.4,
183
+ median_color=None,
184
+ median_contrast: float = 30.0,
185
+ mean_notch_depth: float = 1.5,
186
+ mean_notch_height: float = 2.6,
187
+ whisker_width: float = 1.0,
188
+ whisker_color=None,
189
+ cap_size: float = 4.0,
190
+ cap_width: float = 0.8,
191
+ cap_color=None,
192
+ flier_size: float = 8.0,
193
+ cut_color=None,
194
+ # the individual points
195
+ show_individual_points: bool = True,
196
+ point_size: float = 18.0,
197
+ jitter: float = 0.14,
198
+ point_edge="rim",
199
+ point_edge_width: float = 0.5,
200
+ point_fill_alpha: float = 1.0,
201
+ # colour
202
+ palette=None,
203
+ group_color_position: Optional[float] = None,
204
+ group_color=None,
205
+ point_colors="auto",
206
+ shade_range: tuple = (88.0, 22.0),
207
+ interleave_shades: bool = True,
208
+ # paired / repeated measures
209
+ connect_identical_points_across_x_values: bool = False,
210
+ connect_line_width: float = 0.6,
211
+ connect_color=None,
212
+ connect_alpha: float = 0.8,
213
+ # identity (the sidecar names)
214
+ series=None,
215
+ unit_series=None,
216
+ label_axes: bool = True,
217
+ zorder: float = 2.0,
218
+ ) -> FluxboxResult:
219
+ """Draw a fluxbox: the individual points plus a slim box plot with a median line and a mean notch.
220
+
221
+ Parameters
222
+ ----------
223
+ data
224
+ A pandas or polars DataFrame, a dict of columns, or ``None`` when ``x``/``y``/``units`` are
225
+ passed as arrays.
226
+ x, y
227
+ Column names (or array-likes): ``x`` is categorical (one box per value), ``y`` numeric. Rows
228
+ whose ``x`` or ``y`` is missing are not drawn.
229
+ units
230
+ Optional identity column (subject, animal, cell…). Each unit gets a fixed lane and colour
231
+ inside every category, derived from the table order (``unit_order``), never from the values
232
+ — so separate plots of different measures from the same table agree, even when a unit is
233
+ missing a value in one of them. Units are also what
234
+ ``connect_identical_points_across_x_values`` joins.
235
+ order, unit_order
236
+ Category order (left → right) and unit order (first lane → last). Default: first appearance
237
+ in the data (numeric categories are sorted). Categories missing from ``order`` are dropped.
238
+ ax
239
+ Target axes (default: the current axes).
240
+
241
+ whis
242
+ How far the whiskers reach: a factor of the IQR (default ``1.5`` — Tukey's rule, as
243
+ ``plt.boxplot``), ``"range"`` (the minimum and maximum), or a ``(low, high)`` pair of
244
+ percentiles. Each whisker ends on the most extreme observation within that reach.
245
+ show_mean, show_median, show_whiskers, show_caps
246
+ Toggle the mean notch, the median line, the whiskers and whisker caps (off by default).
247
+ show_fliers
248
+ Mark the observations beyond the whiskers on the box's own axis: ``"auto"`` (only when the
249
+ individual points are hidden, since otherwise they are already drawn — default), ``True``
250
+ or ``False``.
251
+ box_width, box_alpha
252
+ Box width in points (the median line spans exactly this width), and the opacity of its fill
253
+ (default ``0.5``: a wash of the group colour that the solid median line stands out from).
254
+ box_offset, box_side
255
+ Distance (category units) of the box from its category's centre, and its side: ``"outer"``
256
+ (the first category's box to the left, every other to the right, so the boxes frame the
257
+ comparison — default), ``"left"`` or ``"right"``.
258
+ median_line_width, median_color, median_contrast
259
+ Stroke of the median line (points) and its colour. By default it is the group colour,
260
+ deepened — or, where the box renders darker than the group colour (a dark background),
261
+ lifted — only as far as it takes to differ from the box by ``median_contrast`` units of
262
+ perceived lightness (0–100 scale); give ``median_color`` to set it outright. Whichever it is,
263
+ it is also the colour of the whiskers, fliers and a solid (out-of-box) mean mark.
264
+ mean_notch_depth, mean_notch_height
265
+ How far each V-cut of the mean notch reaches into the box, and its height, in points. The
266
+ notch is drawn above the median, so it stays visible when the mean meets the median.
267
+ whisker_width, whisker_color
268
+ Whisker stroke (points) and colour (default: the median's colour).
269
+ cap_size, cap_width, cap_color
270
+ Whisker cap length and stroke (points), and colour (default: the whiskers') — when
271
+ ``show_caps=True``. A whisker of zero length (no observation beyond the box) gets no cap.
272
+ flier_size
273
+ Marker area (points²) of the fliers.
274
+ cut_color
275
+ Colour of the mean notch "cut" (default: the axes background); also the ground the median's
276
+ contrast is judged against.
277
+
278
+ show_individual_points
279
+ Draw the individual observations (default ``True``).
280
+ point_size, jitter
281
+ Marker area (points²), and the half-width of the lane spread (category units). Lanes are
282
+ evenly spaced, never random.
283
+ point_edge, point_edge_width
284
+ ``"rim"`` (a deeper shade of each point's own colour, which keeps pale points crisp —
285
+ default), ``"none"`` or any colour; and the edge width in points.
286
+ point_fill_alpha
287
+ Opacity of the points' fill only (default ``1.0``). The rim / edge keeps full opacity, so
288
+ translucent points stay crisply outlined where they overlap.
289
+
290
+ palette
291
+ Per-category colour source, given once for every category, as a list (cycled in category
292
+ order) or as a ``{category: spec}`` mapping — any colormap or palette in fluxplot's colour
293
+ library, a matplotlib colormap, a list of colours, or a single colour; exactly as for
294
+ :func:`fluxplot.glowbar`. Default: the ColorBrewer maps ``YlGnBu``, ``YlOrRd``, ``RdPu``,
295
+ ``BuGn``, ``Purples``, ``YlOrBr``.
296
+ group_color_position
297
+ Pin the group colour to a point of an ordered (sequential) source's light → dark ramp
298
+ (0 = palest, 1 = darkest). Default ``None``: 0.75 along a ColorBrewer ramp, else the
299
+ source's most chromatic mid-lightness colour (or a neutral ink for multi-hue sources).
300
+ group_color
301
+ Override the group colour (the box, and the base of the median / whisker ink) outright: one
302
+ colour, or a ``{category: colour}`` mapping. Point colours still come from ``palette``.
303
+ point_colors
304
+ ``"shades"`` (each unit its own shade of its category's map), ``"group"`` (every point in its
305
+ group colour), ``"auto"`` (shades with ``units``, else group — default), one colour for all
306
+ points, or a ``{unit: colour}`` mapping.
307
+ shade_range, interleave_shades
308
+ Lightness of the palest and darkest shade (0 = black, 100 = white), spaced in equal
309
+ perceptual steps; and whether shades are dealt across lanes so neighbours always contrast
310
+ (default ``True``) or run pale → dark in lane order.
311
+
312
+ connect_identical_points_across_x_values
313
+ Join each unit's points across the x categories with a line (needs ``units``) — for paired
314
+ or repeated-measures designs. A unit missing from a category breaks its line there instead
315
+ of bridging the gap; several rows of one unit in one category are joined through their mean.
316
+ connect_line_width, connect_color, connect_alpha
317
+ Connector stroke (points), colour (a colour, or ``"unit"`` for each unit's own point colour)
318
+ and opacity. The default is a quiet neutral — the active theme's grid colour, else Flexoki
319
+ base-300 — so the lines never compete with the points.
320
+
321
+ series, unit_series
322
+ Override the series names — the roots of every part id — per category / per unit, as a
323
+ mapping or a callable. Defaults: the category and unit values (``"SD"`` → ``sd.box``,
324
+ ``"B6_8"`` → ``b6-8.points``).
325
+ label_axes
326
+ Put the category names on the x ticks and the ``y`` column name on the y axis, and set the x
327
+ limits (default ``True``).
328
+ zorder
329
+ Base z-order: connectors sit just below it, the points at it, the box above.
330
+
331
+ Returns
332
+ -------
333
+ FluxboxResult
334
+ The axes, category order, statistics, colours, series names and artists.
335
+ """
336
+ import matplotlib.pyplot as plt
337
+
338
+ if ax is None:
339
+ ax = plt.gca()
340
+ if box_side not in _SIDES:
341
+ raise ValueError(f"fluxbox: box_side must be 'outer', 'left' or 'right'; got {box_side!r}")
342
+ if show_fliers not in (True, False, "auto"):
343
+ raise ValueError(f"fluxbox: show_fliers must be True, False or 'auto'; got {show_fliers!r}")
344
+ if not 0.0 <= box_alpha <= 1.0:
345
+ raise ValueError(f"fluxbox: box_alpha must be in [0, 1]; got {box_alpha!r}")
346
+ rule = _whisker_rule(whis)
347
+ cut = _cut_colour(ax, cut_color) # the ground every ink is judged against
348
+ fr = _frame("fluxbox", data, x, y, units, order, unit_order, jitter=jitter, palette=palette,
349
+ group_color=group_color, group_color_position=group_color_position,
350
+ point_colors=point_colors, shade_range=shade_range,
351
+ interleave_shades=interleave_shades, series=series, unit_series=unit_series,
352
+ connect=connect_identical_points_across_x_values, point_fill_alpha=point_fill_alpha,
353
+ ground=cut)
354
+ reg = _tagger.registry_for(ax.figure)
355
+ artists = {"box": [], "whiskers": [], "caps": [], "median": [], "mean": [], "fliers": [],
356
+ "points": [], "lines": []}
357
+ artists["lines"], artists["points"] = _draw_units(
358
+ fr, ax, reg, connect=connect_identical_points_across_x_values,
359
+ connect_line_width=connect_line_width, connect_color=connect_color,
360
+ connect_alpha=connect_alpha, show_points=show_individual_points, point_size=point_size,
361
+ point_edge=point_edge, point_edge_width=point_edge_width,
362
+ point_fill_alpha=point_fill_alpha, zorder=zorder, ground=cut)
363
+ fliers_on = not show_individual_points if show_fliers == "auto" else show_fliers
364
+
365
+ # ---- the box ---------------------------------------------------------------------------------------
366
+ notch, notch_ms = _notch_marker(box_width, mean_notch_depth, mean_notch_height)
367
+ stats = {}
368
+ for k, c in enumerate(fr.cats):
369
+ st = _box_stats(fr.ys[fr.rows_in[c]], rule)
370
+ if st is None:
371
+ continue
372
+ s, col = fr.series_of[c], fr.group_colors[c]
373
+ bx = fr.summary_x(k, box_side, box_offset)
374
+ st["x"] = bx
375
+ stats[c] = dict(st, groupColor=col)
376
+ q1, q3, w_lo, w_hi = st["q1"], st["q3"], st["whiskerLow"], st["whiskerHigh"]
377
+ # one ink for every solid part (median, whiskers, caps, fliers); the box alone is the wash
378
+ ink = median_color if median_color is not None else \
379
+ _median_ink(col, box_alpha, cut, median_contrast)
380
+ whisker_ink = whisker_color if whisker_color is not None else ink
381
+ # a whisker (and its cap) only where an observation lies beyond the box on that side
382
+ ends = [(w, q) for w, q in ((w_lo, q1), (w_hi, q3)) if w != q]
383
+
384
+ if show_whiskers and ends:
385
+ wx, wy = [], []
386
+ for w, q in ends:
387
+ wx += [bx, bx, np.nan]
388
+ wy += [q, w, np.nan]
389
+ (wh,) = ax.plot(wx[:-1], wy[:-1], color=whisker_ink, lw=whisker_width, solid_capstyle="butt", zorder=zorder + 0.5)
390
+ reg.add(Mark(role="whisker", series=s, name="whiskers", kind="fluxbox", artists=[wh]))
391
+ artists["whiskers"].append(wh)
392
+ if show_caps and ends:
393
+ (caps,) = ax.plot([bx] * len(ends), [w for w, _ in ends], ls="none", marker="_",
394
+ ms=cap_size, mew=cap_width,
395
+ color=cap_color if cap_color is not None else whisker_ink,
396
+ zorder=zorder + 0.5)
397
+ reg.add(Mark(role="cap", series=s, name="caps", kind="fluxbox", artists=[caps]))
398
+ artists["caps"].append(caps)
399
+ if fliers_on and st["outliers"]:
400
+ fl = ax.scatter([bx] * len(st["outliers"]), st["outliers"], s=flier_size, color=[ink],
401
+ linewidths=0, zorder=zorder + 0.5)
402
+ reg.add(Mark(role="flier", series=s, name="fliers", kind="fluxbox", artists=[fl]))
403
+ artists["fliers"].append(fl)
404
+
405
+ # the box: a butt-capped stroke exactly box_width points wide, spanning Q1–Q3 in data units;
406
+ # the exact statistics drawn ride on it → the manifest
407
+ (body,) = ax.plot([bx, bx], [q1, q3], color=col, alpha=box_alpha, lw=box_width,
408
+ solid_capstyle="butt", zorder=zorder + 0.6)
409
+ payload = {"fluxbox": {"part": "summary", "category": _plain(c), "units": fr.units_name,
410
+ "groupColor": _hex(col), "palette": _palette_spec_json(fr.palette_used.get(c)),
411
+ **{key: _manifest_value(v) for key, v in st.items()}}}
412
+ reg.add(Mark(role="box", series=s, kind="fluxbox", artists=[body], data=payload))
413
+ artists["box"].append(body)
414
+
415
+ if show_median:
416
+ # marker '_' spans exactly ms points: the median line is as wide as the box
417
+ (med,) = ax.plot([bx], [st["median"]], ls="none", marker="_", ms=box_width,
418
+ mew=median_line_width, color=ink, zorder=zorder + 1.5)
419
+ reg.add(Mark(role="median", series=s, kind="fluxbox", artists=[med]))
420
+ artists["median"].append(med)
421
+ if show_mean:
422
+ # inside the box the notch is cut out of it; outside (or on a box of no height) there is
423
+ # nothing to cut, so the same V's are drawn solid in the ink, pointing in at the whisker
424
+ cut_in = q1 < q3 and q1 <= st["mean"] <= q3
425
+ (mean_mk,) = ax.plot([bx], [st["mean"]], ls="none", marker=notch, ms=notch_ms,
426
+ mfc=cut if cut_in else ink, mec="none", mew=0, zorder=zorder + 2)
427
+ reg.add(Mark(role="mean", series=s, kind="fluxbox", artists=[mean_mk]))
428
+ artists["mean"].append(mean_mk)
429
+
430
+ _finish(fr, ax, label_axes)
431
+ return FluxboxResult(ax=ax, categories=fr.cats, stats=stats, group_colors=fr.group_colors,
432
+ point_colors=fr.point_colors_by_unit(), series=fr.series_of,
433
+ unit_series=fr.unit_series_of, artists=artists)