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,1199 @@
1
+ Metadata-Version: 2.5
2
+ Name: fluxplot
3
+ Version: 0.1.0
4
+ Summary: matplotlib, but every meaningful thing has a name — semantic SVG plots for the Flux ecosystem
5
+ Project-URL: Homepage, https://github.com/fluxsci/fluxplot
6
+ Project-URL: Repository, https://github.com/fluxsci/fluxplot
7
+ Project-URL: Issues, https://github.com/fluxsci/fluxplot/issues
8
+ Author: Kort Driessen
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ License-File: THIRD_PARTY_NOTICES.md
12
+ Keywords: flux,matplotlib,scientific-visualization,semantic,svg
13
+ Requires-Python: >=3.9
14
+ Requires-Dist: cmasher
15
+ Requires-Dist: jsonschema>=4
16
+ Requires-Dist: lxml>=4.9
17
+ Requires-Dist: matplotlib<4,>=3.7
18
+ Requires-Dist: numpy
19
+ Requires-Dist: scipy>=1.9
20
+ Requires-Dist: seaborn>=0.13.2
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=7; extra == 'dev'
23
+ Provides-Extra: mesh
24
+ Requires-Dist: fast-simplification<0.2,>=0.1.13; extra == 'mesh'
25
+ Requires-Dist: nibabel>=5; extra == 'mesh'
26
+ Requires-Dist: trimesh<5,>=4; extra == 'mesh'
27
+ Provides-Extra: notebook
28
+ Requires-Dist: ipykernel>=6.31.0; extra == 'notebook'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # FluxPlot
32
+
33
+ *matplotlib, but every meaningful thing has a name.*
34
+
35
+ FluxPlot is a thin, additive layer over [matplotlib](https://matplotlib.org/). You keep plotting in
36
+ real matplotlib; FluxPlot records **what each mark means** as you draw it, and `fluxplot.save()` turns
37
+ your figure into a **semantic SVG** — a plot whose every part (a series' line, its 4th data point, the
38
+ x-axis title) is individually named, addressable, and restylable, with a sidecar that knows the data
39
+ behind every pixel.
40
+
41
+ This is the keystone format of the **Flux** ecosystem. The same file is, without compromise, a
42
+ publication figure, an animatable slide element, a reproducible artifact, and an object an AI agent
43
+ (or you, in a terminal) can point at precisely.
44
+
45
+ ---
46
+
47
+ ## The problem it solves
48
+
49
+ When matplotlib (or any plotting library) exports an SVG, it produces a flat soup of anonymous shapes:
50
+
51
+ ```xml
52
+ <g id="line2d_7"><path d="M 46 160 L 96 97 …"/></g>
53
+ <g id="PathCollection_1"><use x="46" y="179"/><use x="147" y="141"/> …</g>
54
+ ```
55
+
56
+ A human looking at the rendered picture knows that one path is "the control group" and that dot is
57
+ "the treatment value at hour 12." **The file does not.** That meaning existed at *plot time* — your
58
+ code had the arrays, knew the series names, knew the axis was log-scaled — and then it was thrown away
59
+ the instant the figure was flattened to pixels and anonymous geometry.
60
+
61
+ Everything you might want to do later needs that meaning back:
62
+
63
+ - **Edit one part** of a figure ("make the control line red, the axis labels 8pt") — you have to know
64
+ which shape is which.
65
+ - **Animate it** ("draw the lines, then stagger the points in") — you have to address parts by role.
66
+ - **Morph it** ("the data moves from the t-test version to the Mann-Whitney version") — you have to
67
+ interpolate in *data* space, which means knowing each point's value and the axis scale.
68
+ - **Ask about it** ("what's the peak treatment value?") — you need numbers, not pixel coordinates.
69
+
70
+ You cannot reliably recover any of this from a flattened SVG. Colors collide, log axes look linear,
71
+ two points at the same pixel are indistinguishable. **The only place the meaning is knowable for
72
+ certain is at the moment the plot is drawn.** So that is where FluxPlot captures it.
73
+
74
+ > **Principle 1 — meaning is captured at birth, never reverse-engineered.** FluxPlot tags marks *as
75
+ > your code draws them*, while the program still knows what they are. This single idea rules out the
76
+ > whole category of "post-process an existing SVG with heuristics" — by then it's already guesswork.
77
+
78
+ ---
79
+
80
+ ## Install & quickstart
81
+
82
+ ```bash
83
+ pip install fluxplot # depends on matplotlib, numpy, lxml, jsonschema
84
+ ```
85
+
86
+ ```python
87
+ import matplotlib.pyplot as plt
88
+ import fluxplot as fp
89
+
90
+ t = [0, 4, 8, 12, 16, 20, 24]
91
+ control = [0.02, 0.05, 0.13, 0.41, 0.95, 1.6, 1.9]
92
+ treatment = [0.02, 0.07, 0.25, 0.80, 1.5, 1.95, 2.1]
93
+
94
+ fig, ax = plt.subplots(figsize=(6.4, 4.8))
95
+ fp.line(ax, t, control, series="control", marker="o", label="Control")
96
+ fp.line(ax, t, treatment, series="treatment", marker="s", label="Treatment")
97
+ ax.set_xlabel("Time (h)"); ax.set_ylabel("OD600"); ax.set_yscale("log"); ax.legend()
98
+
99
+ fp.significance_bracket(ax, x0=20, x1=24, y=2.0, label="**",
100
+ between=("control", "treatment"), p=0.003)
101
+
102
+ fp.save(fig, "plots/growth.svg")
103
+ ```
104
+
105
+ You write **ordinary matplotlib** — `fp.line` is `ax.plot` with a `series=` name attached. `fp.save`
106
+ then produces three files. The recipe is rerunnable with zero ceremony: when called from a `.py`
107
+ script, `save` discovers the producing script automatically (deterministic, conservative rules —
108
+ never notebook history, never a guess) and records how it was discovered
109
+ (`provenance.scriptDiscovery`: `automatic` / `explicit` / `unavailable`). Pass
110
+ `recipe=dict(script=..., params={...}, inputs=[...])` to record parameters and input hashes —
111
+ explicit fields always win — or `recipe=False` to suppress discovery entirely (notebooks,
112
+ privacy-sensitive callers). Inputs are **never** discovered automatically.
113
+
114
+ ```
115
+ plots/growth.svg ← the semantic SVG (renders & edits like any vector, but every part is named)
116
+ plots/growth.fluxplot.json ← the manifest (the data + coordinate mapping + build order behind the picture)
117
+ plots/growth.recipe.json ← the recipe (how it was made — script, params, input hashes)
118
+ ```
119
+
120
+ That's the whole library surface for most users: name your series, call `save`.
121
+
122
+ ---
123
+
124
+ ## One plot, four lives
125
+
126
+ From that **one source**, four jobs are served without compromise:
127
+
128
+ | Job | What consumes it | What it needs that a flat SVG can't give |
129
+ |-----|------------------|------------------------------------------|
130
+ | **Publication figure** | Flux Figure, Illustrator, Inkscape | edit a part by name; restyle a whole role ("all axis titles → Helvetica 8pt") |
131
+ | **Animated slide** | Flux Slide | reveal/morph parts *by role and identity* ("draw every line, stagger every point") |
132
+ | **Reproducible artifact** | the recipe + your code | rerun with a different test; regenerate; the references survive |
133
+ | **Agent-addressable object** | an AI agent, a script, the CLI | "the control series' 4th point" = a real, resolvable handle |
134
+
135
+ The design rule is strict: *if a choice helps one job but breaks another, it's wrong.* That's why the
136
+ output is built on SVG (vector **and** a structured, web-native, universally-interoperable tree) rather
137
+ than a raster, a canvas, or a private JSON scene graph.
138
+
139
+ ---
140
+
141
+ ## The three files (and why three)
142
+
143
+ ### `growth.svg` — the renderable, addressable truth
144
+ A completely normal SVG: open it in any browser or vector editor and it just works (it never *depends*
145
+ on the manifest — degrade gracefully, always). What makes it *semantic* is that FluxPlot has added, to
146
+ each meaningful element:
147
+
148
+ - a **stable, meaningful `id`** — `control.line`, `control.point.3`, `axis.x.title`, `legend`;
149
+ - **`data-*` attributes** carrying role + identity (+ the datum value, for convenience):
150
+
151
+ ```xml
152
+ <g id="control.line" data-role="line" data-series="control"><path d="…"/></g>
153
+ …
154
+ <use id="control.point.3" data-role="point" data-series="control"
155
+ data-index="3" data-x="12" data-y="0.41" x="182.05" y="103.5"/>
156
+ ```
157
+
158
+ Text stays as real, editable `<text>` (axis titles, tick labels) — not outlined to paths — so it can be
159
+ restyled and read.
160
+
161
+ ### `growth.fluxplot.json` — the semantic index
162
+ A sidecar that **points into the SVG by id** and adds everything SVG can't naturally express. It never
163
+ duplicates geometry (no path data, no bounding boxes — those live authoritatively in the SVG). It
164
+ holds:
165
+
166
+ ```jsonc
167
+ {
168
+ "axes": [{
169
+ "x": { "scale": "linear", "domain": [-1.2, 25.2],
170
+ "anchors": [{"data": -1.2, "svg": 57.6}, {"data": 25.2, "svg": 414.72}] },
171
+ "y": { "scale": "log", "base": 10, "domain": [0.0158, 2.6767],
172
+ "anchors": [{"data": 0.0158, "svg": 307.6}, {"data": 2.6767, "svg": 41.5}] }
173
+ }],
174
+ "series": [{ "id": "control", "kind": "line", "svg": {"line": "control.line", "points": "control.points"},
175
+ "data": { "x": [0,4,8,…], "y": [0.02,0.05,0.13,…] },
176
+ "points": [ … {"index": 3, "svgId": "control.point.3", "x": 12, "y": 0.41} … ] }],
177
+ "build": { "order": ["axis.x","axis.y","gridlines","control.line","treatment.line",
178
+ "control.points","treatment.points","legend","significance-bracket.0"],
179
+ "presets": { "line": {"animation": "draw-on"}, "point": {"animation": "stagger-in"} } }
180
+ }
181
+ ```
182
+
183
+ This is the file an agent or Flux Slide reads *first* — it's where the *meaning* lives.
184
+
185
+ ### `growth.recipe.json` — the provenance
186
+ The script (auto-discovered, or recorded explicitly), the parameters, references (+ hashes) of the
187
+ input data, and a `provenance` block (script hash, interpreter, package versions, git state when
188
+ available). Enough to **re-run the plot here** — which is what makes "rerun Figure 6d with a
189
+ Mann-Whitney test" a real operation. All host-varying material lives here, never in the SVG/manifest.
190
+
191
+ A plot made in a notebook cell has no script to re-run. `fp.save(..., recipe={"notebook": path,
192
+ "cell": "fig-growth"})` records it honestly: `provenance.scriptDiscovery: "notebook"` and a
193
+ `notebook: {path, cell, sha256}` block, with no `command`. In a live kernel the notebook is
194
+ detected from what the host states outright — `$QUARTO_DOCUMENT_PATH`, or the
195
+ `__vsc_ipynb_file__` / `__session__` globals — and never guessed from the working directory.
196
+
197
+ ### Consistency between the three files
198
+ The manifest records `artifact.svgSha256` — the checksum of the final SVG bytes. `save` stages all
199
+ three files and commits them with atomic per-file renames (SVG → manifest → recipe), so a watcher
200
+ never sees a partially written file, and a stale SVG/manifest pair is *detectable* via the checksum
201
+ rather than silently misinterpreted.
202
+
203
+ **Why split SVG and manifest?** Because they answer different questions in the representation each is
204
+ good at. The SVG answers *"how does it look and which part is which?"* — that belongs in a vector
205
+ tree. The manifest answers *"what does it mean and how does it move?"* — nested numeric data,
206
+ coordinate transforms, choreography — which is miserable to cram into SVG attributes and natural as
207
+ JSON. The authority rule keeps them from drifting: **geometry is authoritative in the SVG; data,
208
+ coordinate-mapping, and choreography are authoritative in the manifest; identity + role appear in both
209
+ as the join key.**
210
+
211
+ ---
212
+
213
+ ## How it actually works (under the hood)
214
+
215
+ FluxPlot rides matplotlib's one real hook from "an artist" to "a named SVG element":
216
+ `artist.set_gid("control.line")` makes matplotlib's SVG backend wrap that artist's output in
217
+ `<g id="control.line">…</g>`. The pipeline in `save()` is:
218
+
219
+ 1. **Assign deterministic gids.** Walk the marks you tagged and give each a semantic id (`ids.py`).
220
+ 2. **Auto-tag the scaffold.** Name the axes, tick labels, legend, and title so you never have to.
221
+ 3. **Capture coordinates.** Read each axis's real transform and record the data↔pixel mapping + scale
222
+ (see below).
223
+ 4. **Render deterministically.** Save to SVG with the determinism knobs set (see below).
224
+ 5. **Inject `data-*` + canonicalize.** matplotlib writes only `id`, never arbitrary attributes — so a
225
+ post-render pass (`postprocess.py`, via lxml) finds each element **by the id we ourselves set** and
226
+ adds `data-role`/`data-series`/… , splits the points group into addressable per-point elements, and
227
+ strips volatile metadata.
228
+ 6. **Emit the manifest and recipe.**
229
+
230
+ > **"Isn't step 5 the reverse-engineering you said was a sin?"** No — and the distinction is the whole
231
+ > point. Reverse-engineering means looking at an anonymous `<path>` and *guessing from its color or
232
+ > shape* that it's the control line. Here, **we set `id="control.line"` before rendering**, so the
233
+ > post-pass is an *exact structural join on an id we authored* plus annotation with values our code
234
+ > already had. Nothing is inferred from pixels. It's serialization, not divination.
235
+
236
+ ### Per-point addressability
237
+ "The 4th point of the control series" needs each point to be its *own* element. matplotlib draws a
238
+ set of markers as one artist: a marker glyph defined once in `<defs>`, then one `<use>` per point in
239
+ data order. FluxPlot's post-pass enumerates those `<use>` children and stamps each with
240
+ `id="control.point.k"` + `data-index`/`data-x`/`data-y`. (If matplotlib ever culls off-axis points and
241
+ the count stops matching the data, FluxPlot detects the mismatch and keeps the group addressable
242
+ rather than mislabel indices.)
243
+
244
+ ### Heavy layers are rasterized by default
245
+ An artist becomes as many SVG nodes as it draws primitives. A `LineCollection` built from per-edge
246
+ segments — the ordinary way to draw an SWC reconstruction or a graph — emits **one `<path>` per
247
+ segment**, and a `scatter` emits **one `<use>` per point**. At real data scale that is 10⁴–10⁵ nodes
248
+ in a single panel, and consumers inline that markup as live DOM, where it is ruinous. (Measured: a
249
+ 14-panel figure carrying three neuron reconstructions and 8.7k-point scatters reached 260,907 nodes
250
+ and ~390 ms per pan frame — about 2.5 fps.)
251
+
252
+ So `fp.save` rasterizes any artist over `raster_threshold` primitives (default 800) into a single
253
+ embedded `<image>` at `raster_dpi` (default 600), and says so:
254
+
255
+ ```
256
+ fluxplot: 'medoid' — rasterized 2 heavy layer(s) at 600 dpi: axon.x-morphology (72,586 primitives),
257
+ dendrite.x-morphology (4,199 primitives). Axes, ticks, labels and legend stay vector.
258
+ Pass force_vectors=True to keep everything as vectors.
259
+ ```
260
+
261
+ **Only the heavy layer is rasterized.** Axes, spines, ticks, tick labels, the legend, annotations and
262
+ every lighter series stay fully vector and fully editable — this is matplotlib's own `set_rasterized`
263
+ applied per artist, which is what journals expect for dense scatter and line art anyway. The
264
+ rasterized layer **keeps its id, its `data-role`/`data-series` and its manifest entry**, so it stays
265
+ addressable as a whole; only *per-point* ids are unavailable, because a rasterized cloud has no
266
+ per-point nodes. `SaveResult.rasterized` lists what was rasterized and the manifest marks those
267
+ entries `"rasterized": true`.
268
+
269
+ On a real morphology panel: **13.42 MB / 76,852 nodes → 0.06 MB / 67 nodes**, rendering
270
+ pixel-indistinguishable (mean channel difference 0.11/255).
271
+
272
+ Opt out with `fp.save(..., force_vectors=True)` or `FLUXPLOT_FORCE_VECTORS=1`; the heavy layers are
273
+ then still reported, as a warning naming them and their node cost. A per-artist
274
+ `set_rasterized(False)` does **not** override the default — that is matplotlib's factory setting
275
+ rather than a considered choice, and silently emitting an unusable SVG is the failure this exists to
276
+ prevent. `force_vectors` is how you say you meant it.
277
+
278
+ ### Determinism — the same plot always produces the same bytes
279
+ This is non-negotiable, because **morphing, diffing, and regeneration all break if ids or structure
280
+ wobble between runs.** FluxPlot pins the sources of nondeterminism:
281
+
282
+ - `svg.hashsalt` is fixed (otherwise matplotlib salts every clip-path / glyph id with a fresh UUID);
283
+ - `svg.fonttype='none'` keeps text as `<text>` (the default outlines glyphs into font-version-dependent
284
+ path data, *and* makes labels un-restylable);
285
+ - the SVG date metadata is dropped, path-simplification is pinned, and the JSON is written canonically
286
+ (sorted keys, fixed float precision).
287
+
288
+ Result: same input → byte-identical SVG and manifest. Timestamps and content hashes (which *must*
289
+ vary) live only in the recipe, so the SVG/manifest stay stable for diffs and morphs.
290
+
291
+ ### Coordinate capture — why the manifest stores "anchors"
292
+ A morph that moves a point from value 2 to value 8 on a **log** axis must travel correctly, which is
293
+ impossible from pixels alone. So per axis FluxPlot stores the scale type and two `(data, svg)` anchor
294
+ pairs. A consumer interpolates between them — linearly, or in log space for a log axis — and gets the
295
+ exact pixel position for any data value, **without ever reconstructing matplotlib's transform.** Two
296
+ anchors + a scale type is the complete, portable contract. (FluxPlot computes them with a
297
+ dpi-invariant fraction method that round-trips *exactly* against the positions matplotlib actually
298
+ emits — verified in `NOTES_matplotlib_svg.md`.)
299
+
300
+ ---
301
+
302
+ ## Semantic IDs: the universal join key
303
+
304
+ Ids are **derived from meaning**, not random — a dotted path of `[a-z0-9-]` segments:
305
+
306
+ ```
307
+ control.line control.point.3 axis.x.title axis.x.tick.2
308
+ legend annotation.peak reference-line.threshold panel.a
309
+ ```
310
+
311
+ The same id is simultaneously the SVG `id`, the manifest key, the handle a caption or animation step
312
+ refers to, and a coordinate in "meaning space" (`role=point, series=control, index=3`). Making it
313
+ **deterministic** is what lets four different operations work:
314
+
315
+ - **Durable references** — a restyle, a caption, or an animation attached to `control.line` survives the
316
+ plot being **regenerated with new data**: the line is still "the control line" even though its shape
317
+ changed, because it has the same id.
318
+ - **Morphing** — match parts across two versions of a plot by id, then tween.
319
+ - **Legible diffs** — a regenerated plot produces a meaningful git diff instead of noise.
320
+ - **Addressing** — humans and agents name a part the same way the file does.
321
+
322
+ (matplotlib's own ids use underscores and hex hashes and never contain dots, so FluxPlot's dotted
323
+ namespace is provably disjoint from anything it autogenerates.)
324
+
325
+ A series name becomes its id segment by a deterministic slug: accents folded, Greek letters and
326
+ `µ` / `°` / `%` spelled out (`α` → `alpha`, `37 °C` → `37-degc`), super- and subscripts flattened
327
+ (`CO₂` → `co2`), lowercase, spaces to `-`. When dropping characters would let two names collide
328
+ (`IL-6 (pg/mL)` vs `IL-6 [pg/mL]`), a six-hex-digit hash of the name is appended, so distinct names
329
+ stay distinct and stable. Spines are named by side (`axis.x.spine.bottom`, `axis.y.spine.left`).
330
+ When a rule changes an id an older fluxplot emitted, the manifest's `idAliases` maps the old id (or
331
+ a series' old root) to the new one for one minor version, so a consumer resolves saved overrides
332
+ through it instead of losing them.
333
+
334
+ Two orthogonal axes of labeling make this work: **role** = *what kind of thing it is* (`line`,
335
+ `point`, `axis-title`) and **identity** = *which one* (`series=control`, `index=3`). Animation targets
336
+ by role ("draw every `line`"); a caption targets by identity ("the `treatment` series"); a journal
337
+ restyle targets by role across the whole figure ("every `axis-title` → 8pt").
338
+
339
+ ---
340
+
341
+ ## The role vocabulary
342
+
343
+ The part names aren't ad hoc — they're the **Grammar of Graphics** (the model behind ggplot2 and
344
+ Vega-Lite): a plot is *data → marks + scales + guides + annotations*. FluxPlot's v0 core roles:
345
+
346
+ - **Containers:** `figure`, `panel`, `plot-area`, `legend`, `colorbar`, `title`
347
+ - **Scaffold / guides:** `axis`, `spine`, `tick`, `tick-label`, `axis-title`, `gridline`, `background`
348
+ - **Data marks:** `series`, `line`, `point`, `bar`, `area`, `errorbar`, `box`, `violin`
349
+ - **Composite sub-parts:** `whisker`, `cap`, `flier`, `median`, `mean`, `segment`, `regions`
350
+ - **Overlays:** `annotation`, `reference-line`, `highlight-region`, `significance-bracket`, `label`
351
+
352
+ Science is unbounded (heatmaps, networks, brain maps), so the vocabulary is a **versioned core plus a
353
+ namespaced extension mechanism**: anything unrecognized becomes an `x-…` role. An unknown role still
354
+ gets a stable id and still renders and is still addressable — at worst it's a clean, tagged, editable
355
+ figure. The standard only ever *adds* power; it never makes a plot worse than a plain SVG.
356
+
357
+ ---
358
+
359
+ ## The API
360
+
361
+ Two styles, both pure matplotlib underneath — you can mix them freely with raw matplotlib calls.
362
+
363
+ **Convenience helpers** (auto-tagging) — each returns the real matplotlib artist(s):
364
+
365
+ ```python
366
+ fp.line(ax, x, y, *, series, marker=None, label=None, **mpl_kwargs)
367
+ fp.scatter(ax, x, y, *, series, label=None, key=None, **mpl_kwargs) # c=values → a colour scale
368
+ fp.bar(ax, x, height, *, series, **mpl_kwargs)
369
+ fp.errorbar(ax, x, y, *, series, yerr=None, xerr=None, **mpl_kwargs) # <s>.line, <s>.point.k, <s>.cap, <s>.errorbar
370
+ fp.legend(ax, handles=None, labels=None, **mpl_kwargs) # keeps entry → artist for the manifest
371
+ fp.area(ax, x, y1, y2=0, *, series, **mpl_kwargs) # manifest band = {x, y1, y2}
372
+ fp.regression(ax, x, y, *, series, kind="linear"|"poly"|"lowess", ci=0.95, degree=1, points=True) # <s>.fit + <s>.band + points
373
+ fp.kde(ax, values, *, series, bw="scott", fill=False) # <s>.line (+ <s>.fill), grid + density recorded
374
+ fp.step(ax, x, y, *, series, where="pre", **mpl_kwargs) # manifest step = {where, drawstyle}
375
+ fp.stem(ax, x, y, *, series, **mpl_kwargs) # <s>.point.k, <s>.segment, <s>.baseline
376
+ fp.secondary_axis(ax, "top"|"right", functions=(f, g), label=None) # the panel's axis.x2 / axis.y2, transform sampled
377
+ fp.image(ax, data, *, series, pixel_size=None, units="µm", channels=None, luts=None, display_range=None, ...)
378
+ fp.scalebar(ax, length, units="µm", *, loc="lower right", label=None, color=None, thickness=2.0, pad=0.4)
379
+ fp.band(ax, x, lo, hi, *, series, what="95% CI", **mpl_kwargs) # <series>.band beside <series>.line
380
+ fp.box(ax, values, *, series, label=None, include_values=False, **mpl_kwargs) # one box per call
381
+ fp.violin(ax, values, *, series, label=None, include_values=False, **mpl_kwargs) # one violin per call
382
+ fp.hist(ax, values, *, series, bins=None, label=None, include_values=False, **mpl_kwargs)
383
+ ```
384
+
385
+ **Images and scale bars** — a micrograph is data too:
386
+
387
+ ```python
388
+ im = fp.image(ax, np.stack([dapi, gfp]), series="cells", channels=["dapi", "gfp"],
389
+ luts=["blue", "green"], display_range=[(0, 900), None], # None → 1st–99.8th percentiles
390
+ pixel_size=0.325, units="µm", composite="add") # or "max"; value_raster=True
391
+ fp.scalebar(ax, 10, "µm") # a vector bar, exactly 10 data units
392
+ fp.colorbar(im.mappables["gfp"], ax=ax, name="gfp", label="GFP")
393
+ ```
394
+
395
+ `fp.image` takes a `(H, W)` image or a `(C, H, W)` / `(H, W, C)` stack, gives every channel a LUT
396
+ (a colormap name, or a colour meaning a black-to-colour ramp) and a display range (the black and
397
+ white points), composites them into one RGB `imshow` in Python, and puts the axes in physical
398
+ units from `pixel_size`. Each channel is a colour scale in the manifest (`cells.dapi`,
399
+ `cells.gfp`; a 2-D image's is just `cells`) with a linear norm over its display range and
400
+ `recolor: "regenerate"` (`"raster"` with `value_raster=True`, when the channel's values travel
401
+ beside the SVG), and a recipe control of the same name — so Flux's colour-scale editor adjusts a
402
+ channel's brightness/contrast or LUT and reruns. The series' `image` payload records the channels,
403
+ LUTs, display ranges, pixel size, units, extent and composite. `fp.scalebar` is a `Line2D` whose x
404
+ extent is exactly `length` data units, anchored in a corner (`loc`), with its label
405
+ (`"<length> <units>"`) as `scalebar.<n>.label`; it takes the theme's ink. `examples/image_example.py`
406
+ draws a two-channel field.
407
+
408
+ **Geometry a view can move.** Every axis records its tick scheme (`tickLocator` /
409
+ `tickFormatter`: `auto`, `log`, `fixed`, `category`, `date`, …); a bar group's `bar` payload holds
410
+ each bar's `center`, `width`, `baseline` and `length` in data units plus a stable `key` (the
411
+ category under it, also `data-key` on the element); heatmap cells and hexagons carry their
412
+ data-space box as `data-x0` / `data-x1` / `data-y0` / `data-y1` and a `data-key` of `row.col`.
413
+ `capabilities.valueMorph` is true for a series whose members are keyed that way, so two versions
414
+ of the plot can be tweened member by member.
415
+
416
+ **Fits.** `fp.regression` fits `y ~ x` (a line, a polynomial of `degree`, or a lowess smooth with
417
+ span `frac`) and draws the fit (`<s>.fit`), its confidence band (`<s>.band`, the t-interval of
418
+ the mean response; a seeded bootstrap for lowess) and the points as one series, recording
419
+ `regression = {kind, degree, coefficients, r2, p, n, ci, grid, fit}` so a caption states the fit
420
+ as drawn. `fp.kde` draws a Gaussian kernel density (scipy's `gaussian_kde`) as a line, optionally
421
+ filled, recording `kde = {grid, density, bandwidth, method, n}`.
422
+
423
+ **Categories, dates, insets.** On a categorical axis a series' `data` carries `xLabels` (the
424
+ category behind each plotted number), on a date axis `xIso` (ISO-8601), likewise for y. An
425
+ `ax.inset_axes` panel records `insetOf` (its host panel); a `fp.secondary_axis` is the panel's
426
+ `axis.x2` / `axis.y2` with the parent → secondary transform sampled in `axes[].x2.secondary`.
427
+
428
+ **Twin axes and figure-scope parts.** An `ax.twinx()` / `twiny()` is the same panel seen through
429
+ a second value axis, not a panel of its own: its axis is `axis.y2.*` / `axis.x2.*` (title, ticks,
430
+ its spine), the manifest's `axes[0].y2` records its scale, domain and anchors, and every series
431
+ drawn on it carries `axis: "y2"`. (Name a twin with `fp.panel` to keep it a separate panel.)
432
+ Figure-scope artists are named once, unprefixed: `fig.suptitle` → `figure.title`, `supxlabel` /
433
+ `supylabel` → `figure.xlabel` / `figure.ylabel`, `fig.legend()` → `figure.legend` with
434
+ `.entry.k.label` / `.swatch` joined to their series across panels, `fig.text` →
435
+ `figure.annotation.k`; the manifest's `figure` block lists them and the parts tree puts them under
436
+ `figure`, beside the panels.
437
+
438
+ `fp.errorbar` names every part of the composite: the data line (when the format draws one), the
439
+ markers as a per-point group, the caps and the bars, all under one series; `uncertainty` records
440
+ `xerr` / `yerr` broadcast to the N points with `errShape` (`scalar` / `symmetric` / `asymmetric`).
441
+ Legend entries join their series by the **artist** each entry stands for (`fp.legend` keeps the
442
+ mapping for hand-picked handles; `ax.legend()`'s own order is recovered at save), so two series
443
+ labelled alike still resolve, and no entry ever claims a series by text alone when the artists say
444
+ otherwise.
445
+
446
+ `fp.band` is the uncertainty band of a line: registered under the **same series** (so `ctl.band`
447
+ sits beside `ctl.line`), in the line's colour at `alpha=0.25`, with `band = {x, lo, hi, what}` in
448
+ the manifest saying what it is (`"95% CI"`, `"SEM"`, …) — a consumer can re-fit or re-label it
449
+ from the inputs rather than from polygon vertices. `fp.area` records `{x, y1, y2}` likewise.
450
+
451
+ `fp.box`/`fp.violin`/`fp.hist` wrap matplotlib's documented return structures, so every statistic
452
+ is individually addressable and grouped per series (`control.whiskers`, `control.medians`, …).
453
+ `fp.hist` records the exact `binEdges`/`counts` in the manifest's `distribution` payload; raw
454
+ sample values are recorded only with `include_values=True` (samples can be large or sensitive).
455
+
456
+ **Surface maps** — a value per mesh vertex, drawn as a set of views and kept addressable:
457
+
458
+ ```python
459
+ fp.surface(ax, values, *, series, surfaces, kind="categorical"|"continuous", ...)
460
+ ```
461
+
462
+ `surfaces` is a `{hemisphere: (vertices, faces)}` mapping, or paths to GIFTI files (read with
463
+ `nibabel`, an optional dependency). `values` is one value per vertex. A categorical map draws **one
464
+ collection per category**, so every region becomes a separately addressable, separately recolourable
465
+ part (`blocks.regions`, or `blocks.<category>` by name) with a matching legend key; a continuous map
466
+ draws one field plus a colorbar, and the manifest records the complete value→colour contract —
467
+ `cmap`, `color_range`, the category→colour table, and which vertices were treated as missing. Views
468
+ (`lateral`, `medial`, …) and hemispheres are laid out side by side inside the one axes.
469
+
470
+ Vertices with no data are an explicit `missing` part rather than a value, so an on-mesh zero stays
471
+ distinguishable from absent data and sentinel codes grey out instead of becoming a spurious category.
472
+ Rendering is orthographic with back-face culling — a fold cannot paint over the surface in front of
473
+ it — with optional Lambertian `shading` for relief; a face straddling a boundary takes the majority
474
+ label rather than being dropped.
475
+
476
+ **Signature fluxplots** — complete, opinionated plot types that are unique to Flux. They take a
477
+ DataFrame (pandas, polars or a dict of columns) plus column names, seaborn-style, and name every
478
+ part for you. The first is the **glowbar**:
479
+
480
+ ```python
481
+ gb = fp.glowbar(df, x="condition", y="APP/GAPDH", units="subject", ax=ax)
482
+ gb = fp.glowbar(df, x="condition", y="signal", units="mouse", # paired / repeated measures
483
+ connect_identical_points_across_x_values=True, ax=ax)
484
+ ```
485
+
486
+ Every observation is a dot; beside each group sits a slim bar that *glows* — its ink densest at
487
+ the centre of the distribution and fading to hard caps at the interval ends — with the **mean** as
488
+ a heavy, haloed line across the bar and the **median** as a V-notch cut into both of its edges.
489
+ The interval is mean ± SEM by default, glowing around the mean; `interval="iqr"` spans the box of
490
+ a box plot and glows around the median, `"sd"` gives mean ± SD, and a callable
491
+ `values -> (low, high)` is accepted. Default groups use ColorBrewer's `YlGnBu`, then `YlOrRd`.
492
+
493
+ With `units=` every unit (animal, subject, cell) keeps a **fixed lane and colour derived from the
494
+ table, never from the values**, so plots of different measures made from one table agree dot for
495
+ dot — even when a unit is missing from one of them. Unit colours are equal *perceptual* steps
496
+ (CAM02-UCS) of each group's ColorBrewer map between `shade_range=(88, 22)` lightness, dealt across
497
+ lanes so neighbours always contrast (`interleave_shades=True`), and rimmed in a deeper shade of
498
+ themselves so the palest dots stay crisp (`point_edge="rim"`). Connectors are a quiet neutral grey
499
+ and break at a unit's missing category rather than bridging it.
500
+
501
+ | part | default id | role |
502
+ |---|---|---|
503
+ | interval glow | `<category>.glow` | `box` |
504
+ | interval caps | `<category>.caps` | `cap` |
505
+ | mean line | `<category>.mean` | `mean` |
506
+ | median notch | `<category>.median` | `median` |
507
+ | a unit's point(s) / connector | `<unit>.points`, `<unit>.point.<k>` / `<unit>.line` | `point` / `line` |
508
+ | points without `units` | `<category>.points`, `<category>.point.<k>` | `point` |
509
+
510
+ Each category series carries a `glowbar` manifest payload with the exact statistics drawn (`n`,
511
+ `mean`, `median`, `sd`, `sem`, `q1`, `q3`, `interval`, `low`, `high`, `center`, `x`, `groupColor`),
512
+ and each unit series its identity (`units`, `unit`, `categories`, `colors`); the manifest's
513
+ `plotType` is `"glowbar"`. Series names default to the category / unit values — `series=` and
514
+ `unit_series=` (a mapping or a callable) rename them. Every visual choice is a keyword:
515
+ `bar_width`, `bar_offset`, `bar_side` (`"outer"` — the default: the first category's bar to
516
+ the left of its points, every other to the right — or `"left"` / `"right"` for all), `glow_steps`, `glow_alpha`, `mean_line_width`, `mean_color`,
517
+ `mean_halo_width`, `median_notch_depth`, `median_notch_height`, `cap_width`, `cap_color`,
518
+ `show_mean`/`show_median`/`show_caps`/`show_individual_points`, `point_size`, `jitter`,
519
+ `point_edge`, `point_fill_alpha` (fill only — the rim stays opaque), `palette` (per category: any
520
+ `fp.colors.maps` colormap such as `"cmasher.emerald"`, any `fp.colors.palettes` palette such as
521
+ `"brewer.Set2"` / `"tol.bright"`, a matplotlib map, a list of colours or one colour — glowbar picks
522
+ as many distinct point colours as it needs plus a solid group colour), `group_color`, `group_color_position`, `point_colors`, `shade_range`,
523
+ `interleave_shades`, `connect_line_width`, `connect_color`, `connect_alpha` (see
524
+ `help(fp.glowbar)`). It returns a `GlowbarResult` (`.ax`, `.categories`, `.stats`, `.group_colors`,
525
+ `.point_colors`, `.series`, `.unit_series`, `.artists`). `examples/glowbar_example.py` draws both
526
+ designs.
527
+
528
+ The **fluxbox** is the glowbar with a box plot for its summary — the same call, the same lanes,
529
+ colours, connectors and names, so the two can be swapped for one another dot for dot:
530
+
531
+ ```python
532
+ fb = fp.fluxbox(df, x="condition", y="APP/GAPDH", units="subject", ax=ax)
533
+ ```
534
+
535
+ Beside each group sits a slim box (Q1–Q3), a half-strength wash of the group colour
536
+ (`box_alpha=0.5`). The **median** is a solid line across it in the group's own hue — deepened, or on
537
+ a dark background lifted, only as far as it takes to differ from the box by `median_contrast=30`
538
+ units of perceived lightness, so it reads for any palette and theme; the whiskers (and fliers) share
539
+ that colour, so the box is the only wash. The **mean** is a V-notch cut
540
+ into both edges of the box — the glowbar's median notch; a mean outside the box (a strongly skewed
541
+ group) keeps its mark as the same two V's drawn solid, pointing in at the whisker. The capless
542
+ whiskers reach the most extreme observations within `whis` × IQR of the box (`1.5` — Tukey's rule,
543
+ exactly `plt.boxplot`'s whiskers), `"range"`, or a `(low, high)` pair of percentiles. Observations
544
+ beyond the whiskers are not drawn again as fliers — the points already show them — unless the
545
+ points are hidden (`show_fliers="auto"`).
546
+
547
+ | part | default id | role |
548
+ |---|---|---|
549
+ | box (Q1–Q3) | `<category>.box` | `box` |
550
+ | whiskers | `<category>.whiskers` | `whisker` |
551
+ | whisker caps (`show_caps=True`) | `<category>.caps` | `cap` |
552
+ | median line | `<category>.median` | `median` |
553
+ | mean notch | `<category>.mean` | `mean` |
554
+ | fliers (points hidden) | `<category>.fliers` | `flier` |
555
+ | points / connectors | as for the glowbar | `point` / `line` |
556
+
557
+ Each category series carries a `fluxbox` manifest payload with the exact statistics drawn (`n`,
558
+ `mean`, `median`, `sd`, `sem`, `q1`, `q3`, `iqr`, `whis`, `whiskerLow`, `whiskerHigh`, `outliers`,
559
+ `x`, `groupColor`); the manifest's `plotType` is `"fluxbox"`. The box keywords are `whis`,
560
+ `box_width`, `box_alpha`, `box_offset`, `box_side`, `median_line_width`, `median_color` (sets the
561
+ median outright), `median_contrast`, `mean_notch_depth`, `mean_notch_height`, `whisker_width`, `whisker_color`,
562
+ `cap_size`, `cap_width`, `cap_color`, `flier_size`, `cut_color` and
563
+ `show_mean`/`show_median`/`show_whiskers`/`show_caps`/`show_fliers`; every point, colour, connector
564
+ and naming keyword is the glowbar's. It returns a `FluxboxResult` with the same fields as a
565
+ `GlowbarResult`. `examples/fluxbox_example.py` draws both designs.
566
+
567
+ The **hexmatrix** tiles the plane with regular hexagons, each one a named part. One call covers a
568
+ point cloud's density, a 2D gradient of a third variable, and a matrix drawn on a hex lattice:
569
+
570
+ ```python
571
+ hm = fp.hexmatrix(df, x="x", y="y", ax=ax, color="#4CB391", marginals=True) # jointplot-style
572
+ hm = fp.hexmatrix(df, x="wake", y="nrem", ax=ax, xscale="log", yscale="log", # log-log density
573
+ norm="log", identity_line=True, colorbar_label="Synapses per hexbin")
574
+ hm = fp.hexmatrix(df, x="ccf_x", y="ccf_z", ax=ax, aspect="equal", binwidth=0.15, # spatial, 0.15 mm bins
575
+ norm="log", colorbar_label="somata / hexbin")
576
+ hm = fp.hexmatrix(df, x="x", y="y", C="rate", reduce="median", ax=ax, # a gradient of C
577
+ cmap="RdBu_r", center=0)
578
+ hm = fp.hexmatrix(matrix=weights, ax=ax, gap=0.08) # a hex lattice map
579
+ ```
580
+
581
+ Hexagons are addressed by their lattice position: `row` counts up the y axis and `col` along x, so
582
+ `<series>.hex.<row>.<col>` names the same hexagon in every plot with the same `extent` and grid,
583
+ whatever the data. They are regular *on the page*. `aspect="auto"` locks the axes' box aspect so a
584
+ later layout pass can't squash them, and `aspect="equal"` bins in true data units. Log axes bin in
585
+ log space.
586
+
587
+ The colour can be a count, `stat="density"`/`"probability"`/`"percent"` (optionally `weights=`), or
588
+ a `reduce` of `C` (`mean`, `median`, `sum`, `min`, `max`, `std`, `count` or a callable). The scale is
589
+ set by `cmap`/`color` (a single-hue ramp), `norm` (`linear`/`log`/`sqrt` or any `Normalize`),
590
+ `vmin`/`vmax`, `robust` and `center`. Further keywords:
591
+
592
+ * `mincnt=0` draws the empty hexagons too;
593
+ * `sparse=k` draws points instead of hexagons where fewer than `k` observations fall;
594
+ * `show_points` overlays every observation;
595
+ * `gap`, `edgecolor`, `linewidth` and `orientation="flat"` shape the hexagons;
596
+ * `marginals`, `colorbar` and `identity_line` add furniture.
597
+
598
+ The colormap and colour limits are recipe controls, exactly as for `fp.heatmap`: Flux's Color scales
599
+ editor can change them and regenerate.
600
+
601
+ | part | default id | role |
602
+ |---|---|---|
603
+ | all hexagons | `<series>.hexes` | `x-hexbin` |
604
+ | one hexagon (`data-row`, `data-column`, `data-x`, `data-y`, `data-count`, `data-value`) | `<series>.hex.<row>.<col>` | `x-hex` |
605
+ | sparse / overlaid points | `<series>-points.points`, `….point.<k>` | `point` |
606
+ | identity line | `reference-line.identity` | `reference-line` |
607
+ | marginal histograms (own panels) | `<series>-x.bar.<k>`, `<series>-y.bar.<k>` | `bar` |
608
+
609
+ The series carries a `hexmatrix` manifest payload: the lattice (orientation, radius, aspect,
610
+ scales, extent), the statistic, and every hexagon drawn (`row`, `col`, `x`, `y`, `count`, `value`).
611
+ It also carries the usual `field` colour payload; the `plotType` is `"hexmatrix"`. The call returns
612
+ a `HexMatrixResult` (`.ax`, `.hexes`, `.bins`, `.cmap`, `.norm`, `.colorbar`, `.marginal_axes`,
613
+ `.points`, `.hex_id(row, col)`, `.lookup(x, y)`). A layer of more than 800 hexagons is rasterized
614
+ like any heavy layer; its hexagons stay in the manifest. Pass `force_vectors=True` to `fp.save` to
615
+ keep every hexagon addressable. `examples/hexmatrix_example.py` draws all five.
616
+
617
+ **Statistics** — `fp.stats` holds the tests behind the plots, one per branch of the house
618
+ statistics guidance. Each two-group test takes `(a, b)`, orients signs as `a - b`, and returns one
619
+ reporting row (a dict keyed by `fp.stats.REPORT_COLUMNS`: `sig_test_used`, `test_statistic_value`,
620
+ `p-value`, `p_corrected_holm`, `dof`, `effect_size_method`, `effect_size_value`,
621
+ `effect_size_95_CI`, then the appended `effect_size_ci_low` / `effect_size_ci_high` (numbers),
622
+ `n_a`, `n_b`, `n_total`, `groups` (the names compared), `alternative`, `p_corrected_bh` and
623
+ `dof_error`), ready to save as a CSV in the plot's `_stats` dissection:
624
+
625
+ ```python
626
+ row = fp.stats.welch_hedges(sd_values, sleep_values, names=("SD", "sleep")) # alternative="two-sided"
627
+ pl.DataFrame([row]).write_csv("plots/_dissections/app_gapdh/_stats/welch_ttest.csv")
628
+ ```
629
+
630
+ | function | design | test | effect size + 95% CI |
631
+ |---|---|---|---|
632
+ | `welch_hedges` | independent, means | Welch's t-test | Hedges' g, non-pooled SD `sqrt((var_a + var_b) / 2)`; Bonett (2008) CI |
633
+ | `mann_whitney_cliff` | independent, ranks | Mann–Whitney U (`U` of `a`) | Cliff's delta; Newcombe (2006) Method 5 score CI |
634
+ | `paired_t_hedges` | paired, mean difference | paired t-test | Hedges' g_z (SD of the differences); exact noncentral-t CI |
635
+ | `wilcoxon_rank_biserial` | paired, ranks | Wilcoxon signed-rank (`W+`, zeros dropped) | Kerby's matched-pairs rank-biserial r; score CI |
636
+
637
+ The CIs target the population effect size, so they are never multiplied by Hedges' `J`. Both
638
+ rank-based CIs stay non-degenerate at complete separation (`delta` or `r` = ±1), which is common at
639
+ n = 6. Rank tests report `dof = None`. `tests/test_stats.py` pins each against scipy and against
640
+ the equation that defines its interval.
641
+
642
+ *Multiple comparisons* — a row on its own has `p_corrected_holm == p-value` (a family of one).
643
+ Holm's step-down correction needs every p-value in the family, so it is a separate pass over the
644
+ rows of the comparisons that belong together (e.g. every measure tested on the same animals in one
645
+ figure); extra keys such as a measure name ride along:
646
+
647
+ ```python
648
+ rows = [dict(measure=m, **fp.stats.welch_hedges(sd[m], sleep[m])) for m in measures]
649
+ rows = fp.stats.holm(rows) # fills p_corrected_holm across the family; order is kept
650
+ ```
651
+
652
+ `fp.stats.holm_adjusted(p)` does the same for a bare array of p-values; `fp.stats.bh(rows)` /
653
+ `bh_adjusted(p)` fill `p_corrected_bh` with Benjamini–Hochberg (false-discovery-rate) values for a
654
+ screen of many measures. NaN p-values pass through both untouched.
655
+
656
+ *Three or more groups* — the omnibus tests return one row (`groups` lists every group; F tests
657
+ carry `dof` / `dof_error`), the post-hoc tests one row per pair, and `pairwise` runs any two-group
658
+ test above over the pairs of a `{name: sample}` family and corrects across them:
659
+
660
+ | function | test | effect size + 95% CI |
661
+ |---|---|---|
662
+ | `anova_oneway(*groups, names=, effect="eta2"\|"omega2")` | one-way ANOVA | η² or ω²; Steiger's noncentral-F CI |
663
+ | `welch_anova(*groups, names=)` | Welch's ANOVA | ω²; noncentral-F CI on Welch's dof |
664
+ | `kruskal_epsilon(*groups, names=)` | Kruskal–Wallis | ε² = H / (N − 1); seeded bootstrap CI |
665
+ | `rm_anova(table, subject, within, dv)` | repeated-measures ANOVA, Greenhouse–Geisser dof and p | partial η²; noncentral-F CI |
666
+ | `friedman_kendall(table, subject, within, dv)` | Friedman | Kendall's W; seeded bootstrap CI |
667
+ | `tukey_hsd(*groups, names=)` | Tukey HSD (family-wise p as is) | Hedges' g, pooled SD |
668
+ | `games_howell(*groups, names=)` | Games–Howell (studentized range on Welch dof) | Hedges' g, non-pooled SD; Bonett CI |
669
+ | `dunn(*groups, names=, adjust="holm")` | Dunn's rank-sum test after Kruskal–Wallis | Cliff's delta; Newcombe CI |
670
+ | `pairwise(test, groups, pairs=None, adjust="holm"\|"bh")` | any two-group test per pair | that test's |
671
+
672
+ `tests/test_stats_multi.py` pins them against pingouin / scikit-posthocs reference values.
673
+
674
+ *From rows to brackets* — `fp.brackets(ax, rows, positions=…)` draws one significance bracket per
675
+ post-hoc row and stacks them automatically: shortest span first, each bracket one `step` above the
676
+ data it spans and above every bracket it overlaps in x (multiplicative steps on a log axis), so
677
+ nothing crosses. `positions` maps group name → x; a glowbar / fluxbox result provides it, and
678
+ `gb.brackets(rows)` is the one-liner:
679
+
680
+ ```python
681
+ rows = fp.stats.pairwise(fp.stats.welch_hedges, {"ctl": ctl, "drug": drug, "sham": sham})
682
+ gb = fp.glowbar(data=df, x="group", y="value", ax=ax)
683
+ gb.brackets(rows) # *** / ** / * / ns from p_corrected_holm
684
+ gb.brackets(rows, label="p", ns=False, p_column="p_corrected_bh") # "p = 0.003", drop ns pairs
685
+ ```
686
+
687
+ `label` is `"stars"`, `"p"`, `"both"` or a callable on the row; `thresholds`, `top`, `step` and
688
+ `tip` shape the stack; extra keywords reach `fp.significance_bracket`. Each bracket's manifest
689
+ overlay carries `between: [a, b]`, `p` and a `stats` block — test, statistic, raw and corrected p,
690
+ correction, effect size and its CI, sizes — so the figure states exactly which test each star came
691
+ from.
692
+
693
+ **Labels are identity** — a *conventional* labeled plot needs no helpers at all. At save time,
694
+ raw artists carrying a public label (`ax.plot(..., label="Control")`, labeled `scatter`/`bar`/
695
+ `fill_between`) are promoted to named series with their exact artist data, marked in the manifest
696
+ with `capture: {identity: artist-label, data: artist}`. Nothing is ever guessed: private/absent
697
+ labels stay addressable as `extra.*`, duplicated labels decline with one actionable warning, and
698
+ role meaning (threshold? fit? band?) is never inferred from geometry or style — use the explicit
699
+ overlays for that.
700
+
701
+ **First-class overlays** (deliberately included because they're ubiquitous in science):
702
+
703
+ ```python
704
+ fp.significance_bracket(ax, *, x0, x1, y, label, between=None, p=None)
705
+ fp.reference_line(ax, *, y=None, x=None, name)
706
+ fp.annotation(ax, *, name, text, **mpl_kwargs)
707
+ ```
708
+
709
+ **The escape hatch** — tag *any* raw matplotlib artist, so you never lose matplotlib's full breadth:
710
+
711
+ ```python
712
+ ln, = ax.plot(x, y, "--", color="k") # plain matplotlib
713
+ fp.tag(ln, role="reference-line", name="model")
714
+
715
+ sc = ax.scatter(x, y)
716
+ fp.tag_points(sc, series="treatment") # make an existing collection addressable per-point
717
+ ```
718
+
719
+ **Seaborn in one line** — seaborn is matplotlib underneath, so `fp.tag_seaborn` inspects what a
720
+ seaborn axes-level call drew (`lineplot`/`scatterplot`/`barplot`/`histplot`/`kdeplot`/`regplot`) and
721
+ names it — mean lines → `line`, CI bands → `area`, points → per-point `point`, bars → `bar` (+ their
722
+ `errorbar`) — using a named plotting adapter (or explicit `series=[...]` in artist draw order):
723
+
724
+ ```python
725
+ sns.lineplot(data=fmri, x="timepoint", y="signal", hue="region", ax=ax)
726
+ fp.tag_seaborn(ax, plot="lineplot") # → {"parietal": ["line","area"], "frontal": [...]}
727
+ ```
728
+
729
+ The categorical kinds are covered too — `plot="boxplot"`, `"violinplot"`, `"stripplot"`,
730
+ `"swarmplot"`, `"pointplot"` — named from seaborn's fixed drawing order, never from colour or
731
+ geometry. Pass the frame the seaborn call took (`data=`, `x=`, `y=`, `hue=`) and the hue levels
732
+ come out in seaborn's own `categorical_order`; a strip / swarm / `scatterplot(hue=)` whose hue
733
+ levels are mixed inside one collection is split by the frame rows each level owns (the collection
734
+ stays one artist; each level lists its own points), and `barplot(x=g, hue=g)` — which seaborn
735
+ draws without a legend — is named from `hue=`. Without `hue` every category is a series (`a.box`,
736
+ `a.whisker`, `a.points`); with it every hue level is a series and each category a named part
737
+ (`p.a` for the box, `p.a-whisker`, `p.a.point.k`). A call that tags nothing warns.
738
+
739
+ ```python
740
+ sns.swarmplot(data=df, x="group", y="value", hue="sex", ax=ax)
741
+ fp.tag_seaborn(ax, plot="swarmplot", data=df, x="group", y="value", hue="sex") # {"F": ["point", …], "M": […]}
742
+ ```
743
+
744
+ **Recipes for artists without a first-class helper** — `fp.tag` covers all of them; these are the
745
+ patterns that come up constantly in practice (copy them verbatim). Unknown roles like `x-heatmap`
746
+ degrade gracefully: they still get stable ids, a `data-role`, and a manifest entry.
747
+
748
+ ```python
749
+ # Stackplot — one PolyCollection per layer, tagged as areas:
750
+ polys = ax.stackplot(x, series_a, series_b, labels=["A", "B"])
751
+ for poly, name in zip(polys, ["a", "b"]):
752
+ fp.tag(poly, role="area", series=name)
753
+
754
+ # Heatmap (imshow or pcolormesh) — the whole image is one addressable mark:
755
+ im = ax.imshow(matrix, aspect="auto", cmap=fx.SEQUENTIAL)
756
+ fp.tag(im, role="x-heatmap", series="counts-by-decade")
757
+
758
+ # Hexbin — the PolyCollection is one mark (per-hex addressing isn't meaningful):
759
+ hb = ax.hexbin(w, h, gridsize=58, xscale="log", yscale="log", bins="log", mincnt=1)
760
+ fp.tag(hb, role="x-hexbin", series="artwork-density")
761
+
762
+ # Ridgeline (a fill_between + outline per row):
763
+ for i, (name, dens) in enumerate(rows):
764
+ band = ax.fill_between(grid, offset(i), offset(i) + dens, alpha=0.8)
765
+ fp.tag(band, role="area", series=f"ridge-{name}")
766
+
767
+ # Horizontal bars on a LOG x-axis — never anchor at 0 (log(0) serializes as a
768
+ # huge off-canvas coordinate; fp.save warns and flux validate-plot rejects it).
769
+ # Draw from 1 so the geometry is finite and the length still encodes count:
770
+ bars = ax.barh(ypos, counts - 1, left=1)
771
+ for i, p in enumerate(bars.patches):
772
+ fp.tag(p, role="bar", series="classification-count", index=i)
773
+ ax.set_xscale("log")
774
+ ```
775
+
776
+ **Export:**
777
+
778
+ ```python
779
+ fp.save(fig, path, *, recipe=None, validate=True)
780
+ ```
781
+
782
+ `save` auto-tags the axes/legend/title for you, so the *only* thing you normally add to a matplotlib
783
+ script is a `series=` on your plotting calls.
784
+
785
+ A **figure-level script** that `fp.save`s several plots stays fully rerunnable per-plot: with
786
+ `FLUXPLOT_ONLY=<name[,name…]>` in the environment (fnmatch patterns work), every non-matching
787
+ `save` becomes a no-op — nothing written, sibling triplets untouched on disk. `flux rerun-plot
788
+ <recipe> --only` sets it for you, so one script per figure and per-panel regeneration coexist.
789
+
790
+ ---
791
+
792
+ ## What downstream tools do with it
793
+
794
+ The file *is* the API — every consumer reads the same artifacts, no private state:
795
+
796
+ - **Flux Figure** inlines the SVG (so its tagged nodes are live, clickable DOM), lets you select a part
797
+ and restyle it, and stores the override **keyed by semantic id** so it survives regeneration.
798
+ - **Flux Slide** reads the manifest's `build.order` + per-role presets to animate by role, and morphs
799
+ between two versions by matching ids and interpolating in data space via the anchors.
800
+ - **A "journal style" pass** restyles a whole figure by role — the figure analogue of restyling
801
+ citations with a CSL file.
802
+ - **An agent** reads the manifest to answer questions, or edits the recipe and re-runs to regenerate a
803
+ panel — and because ids are stable, the caption, layout, and animation re-attach automatically.
804
+
805
+ ---
806
+
807
+ ## What FluxPlot is *not*
808
+
809
+ - **Not a new plotting API or grammar.** It rides matplotlib's; your full matplotlib knowledge applies.
810
+ - **Not a renderer or a matplotlib replacement.** It adds names; it removes nothing.
811
+ - **Not a figure compositor.** Its unit is *one semantically-tagged plot + its manifest + recipe*.
812
+ It supports Matplotlib subplots in one exported plot. Composing independent plot files into a
813
+ publication figure remains Flux Figure's job.
814
+
815
+ A small, sharp contract is adoptable and composable; a sprawling one rots.
816
+
817
+ ---
818
+
819
+ ## Repo layout & development
820
+
821
+ ```
822
+ fluxplot/
823
+ src/fluxplot/
824
+ api.py # public surface + the save() pipeline
825
+ ids.py # the semantic ID grammar (a public, long-lived contract)
826
+ tagger.py # the registry (artist → meaning) + scaffold auto-tagging
827
+ render.py # deterministic SVG rendering (the P5 knobs)
828
+ capture.py # data↔SVG coordinate anchors + scale capture
829
+ postprocess.py # lxml: gid-join, data-* injection, per-point split, canonicalize
830
+ manifest.py # assemble *.fluxplot.json
831
+ recipe.py # assemble *.recipe.json
832
+ roles.py # the role vocabulary (+ x- extensions)
833
+ fields.py # colour-mapped fields: heatmap / contour, colour controls, shared scales, colour keys
834
+ colorscale.py # the portable colour-scale law (LUT lookup) the manifest's colorScales carry
835
+ colors.py, colorcheck.py, style.py # colormap / palette collections, accessibility lint, themes
836
+ images.py # fp.image (channels, LUTs, display ranges) + fp.scalebar
837
+ brackets.py # fp.brackets: stats rows → stacked significance brackets
838
+ fits.py # fp.regression, fp.kde
839
+ seaborn_adapters.py # exact identity for seaborn's categorical plots and hue splits
840
+ signature_fluxplots/ # preset plot types unique to Flux (fp.glowbar, fp.fluxbox, fp.hexmatrix)
841
+ stats/ # tests behind the plots, returning reporting rows (two-group, paired, k-group, post hoc)
842
+ schemas/ # JSON Schemas for the manifest and recipe (validated on every save)
843
+ definitions/ # the shipped colormap / palette / token tables
844
+ examples/growth_plot.py # the worked example above
845
+ examples/glowbar_example.py # the glowbar signature plot, unpaired + paired
846
+ examples/fluxbox_example.py # the fluxbox signature plot, unpaired + paired
847
+ examples/hexmatrix_example.py # the hexmatrix: joint, log-log, spatial, gradient, lattice map
848
+ examples/image_example.py # a two-channel micrograph with a scale bar and per-channel keys
849
+ tests/ # determinism, the marker-DOM probe, ids, capture, schema
850
+ NOTES_matplotlib_svg.md # the verified matplotlib SVG mechanics this rides on
851
+ ```
852
+
853
+ ```bash
854
+ python -m pytest # determinism is byte-checked; the marker-DOM probe guards mpl upgrades
855
+ python examples/growth_plot.py # writes examples/out/growth.{svg,fluxplot.json,recipe.json}
856
+ ```
857
+
858
+ `test_determinism.py` renders twice and asserts byte-identical output; `test_marker_dom.py` asserts
859
+ matplotlib's marker SVG structure so an upgrade that changes it fails loudly instead of silently
860
+ corrupting per-point ids.
861
+
862
+ ---
863
+
864
+ ## Status
865
+
866
+ **v0, in active development.** The library generates semantic SVGs + manifests + recipes today, and
867
+ Flux Figure consumes them (inline render, part selection, restyle-by-part, semantic export). The
868
+ conceptual spec is `../Flux_SemanticSVG_Spec.md`; the verified matplotlib mechanics are
869
+ `NOTES_matplotlib_svg.md`.
870
+
871
+ ## Scientific export and panels (schema 0.3)
872
+
873
+ Saving captures the current artist state: edit a line with `set_data`, remove an annotation,
874
+ then save again. Scientific numbers retain their precision; NaN and masked observations become
875
+ JSON `null` at their original indices. Missing line observations remain gaps. Nonfinite recipe
876
+ parameters are rejected before any existing output file is replaced. Normal figure closing and
877
+ garbage collection release the registry.
878
+
879
+ Use ordinary Matplotlib scalar inputs, categorical bars and datetime coordinates. `barh` shares
880
+ `bar`'s orientation/baseline metadata. Histograms record bin edges, heights, count/density,
881
+ weighting and cumulative settings; `counts` remains the legacy height key. Line markers inherit
882
+ size, color, opacity, z-order and `markevery`. Numeric exported coordinates use Matplotlib's
883
+ converted units, with display tick labels and date epoch information alongside them.
884
+
885
+ ```python
886
+ fig, axes = plt.subplots(1, 2, layout="constrained")
887
+ for name, ax in zip(["baseline", "followup"], axes):
888
+ fp.panel(ax, name) # stable even when the layout changes
889
+ fp.line(ax, [0, 1, 2], [1, 3, 2], series="control")
890
+ fp.save(fig, "comparison.svg")
891
+ ```
892
+
893
+ A legacy single axes keeps IDs such as `control.line`. Multiple axes (including twins and
894
+ insets) use panel namespaces; explicit names produce `panel.baseline.control.line`. Automatic
895
+ names follow layout order. Name panels explicitly when identities must survive rearrangement.
896
+ Colorbars belong to their source panel and do not masquerade as additional plotting axes.
897
+
898
+ Axis tick groups include visible major and minor marks on both sides, with matching
899
+ label and gridline groups. Existing major bottom/left IDs are preserved; minor and
900
+ opposite-side components use `minor` and `secondary` ID segments. Named colorbars
901
+ likewise expose `.ticks` and `.tick-labels` groups, include the visible tick side,
902
+ and report only major tick locations inside the displayed range. Colorbar parts
903
+ carry explicit text/line/shape kinds, and tick marks export as measurable paths.
904
+
905
+ The manifest's `components` inventory includes every repeated role, and both the parts tree
906
+ and build order use that inventory. Coordinates are captured after layout with the SVG renderer.
907
+ `projection`, axis `supported`, and series `capabilities.dataMorph` describe whether data-space
908
+ interpolation is exact. Flux interpolates supported line/scatter plots per panel and preserves
909
+ gaps; unsupported scales, polar/3D projections, raster layers and composite marks use a complete
910
+ transition. A 3D scatter retains group identity without claiming depth-sorted point indices.
911
+
912
+ For multi-hue Seaborn output, pass the plotting adapter: `fp.tag_seaborn(ax, plot="histplot")`,
913
+ `plot="lineplot"`, `plot="barplot"`, etc. Distribution adapters account for reversed hue draw
914
+ order. Explicit `series=[...]` means **artist draw order**. Ambiguous automatic tagging warns
915
+ and leaves addressable extras; it never guesses scientific identity from color.
916
+
917
+ ## Matrices, contours and color keys
918
+
919
+ ```python
920
+ fig, ax = plt.subplots(layout="constrained")
921
+ image = fp.heatmap(ax, [[1, .2], [.2, 1]], series="correlation",
922
+ cmap="viridis", vmin=0, vmax=1, cells=True)
923
+ fp.colorbar(image, name="correlation", label="Correlation")
924
+ fp.save(fig, "correlation.svg")
925
+
926
+ # Ordinary Matplotlib contour arguments/options and return objects:
927
+ fig, ax = plt.subplots()
928
+ bands = fp.contourf(ax, x, y, z, series="energy", levels=[0, 1, 3, 5])
929
+ fp.colorbar(bands, name="energy", label="Energy")
930
+ ```
931
+
932
+ - `heatmap` uses `imshow` by default. Supplying `x` and `y` selects `pcolormesh` and supports
933
+ irregular grids. `cells=True` uses mesh edges (default unit edges, origin at the lower left)
934
+ and names each small-matrix cell by row/column. It does not change the raster budget.
935
+ - `contour` and `contourf` retain exact boundaries and addressable levels/bands, including the
936
+ different ContourSet structures in Matplotlib 3.7 and later.
937
+ - Field metadata records shape, extent or grid, masks, colormap, normalization and its range.
938
+ `include_values=True` additionally records raw matrix values; it is off by default. Regular
939
+ meshes store compact one-dimensional edge arrays. Large layers rasterize as one named part.
940
+ - Named colorbars expose the ramp, label and ticks, and link to the mappable. Ordinary
941
+ `fig.colorbar` calls receive the same automatic guide capture.
942
+
943
+ ### Colour scales: the exact law, portably
944
+
945
+ Every colour-mapped mark — a heatmap, a hexmatrix, a filled contour, a `fp.scatter(..., c=values)`,
946
+ even a raw `ax.imshow` or `ax.pcolormesh` you never tagged — records its complete value → colour
947
+ law in the manifest's `colorScales[]`:
948
+
949
+ ```jsonc
950
+ {"id": "rates", // == the recipe's colour-control key
951
+ "kind": "continuous", // continuous | binned (a BoundaryNorm)
952
+ "colormap": {"name": "viridis", "source": "matplotlib", "N": 256, "lut": ["#440154ff", …],
953
+ "under": "#440154ff", "over": "#fde725ff", "bad": "#00000000", "discrete": false},
954
+ "norm": {"kind": "log", "vmin": 1.0, "vmax": 53.0, "clip": false, "base": 10, "extend": "neither"},
955
+ "mappables": ["rates.hexes"], "colorbars": ["colorbar.color"], "label": "Synapses per hexbin",
956
+ "recolor": "live", // live | raster | regenerate (an imshow is a raster)
957
+ "editable": {"cmap": true, "limits": true, "normKinds": ["linear", "log", "power", "symlog"], "center": false}}
958
+ ```
959
+
960
+ `lut` is the full lookup table matplotlib indexes, so a consumer reproduces its colours exactly:
961
+ `lut[trunc(x · N)]` for the normalised `x`, `under` / `over` beyond the limits, `bad` for a missing
962
+ value (`fluxplot.colorscale.apply` is the reference implementation, tested hex for hex against
963
+ matplotlib for every norm kind; `tests/fixtures/colorscale_vectors.json` carries the vectors Flux
964
+ checks too). In the SVG every coloured element carries the value it was coloured by —
965
+ `data-value` on each cell, hexagon, band (with `data-level-low` / `-high`), contour line and point,
966
+ `data-missing="1"` for a gap — and the group carries `data-color-scale` and `data-paint` (`fill`,
967
+ `stroke` or `fill stroke`), so Flux can recolour or re-range a plot live, without Python.
968
+ `series[].field` and `series[].color.scale` point at the scale; `fp.heatmap(..., value_raster=True)`
969
+ additionally writes the matrix as `<plot>.<key>.values.json` beside an image layer.
970
+
971
+ Colour keys are exact vectors: the 256 solid quads become one `<rect>` filled by a hard-stepped
972
+ `<linearGradient>` (no rasterization, no warning), and the colorbar guide records `anchors`
973
+ (vmin / vcenter / vmax ↔ SVG position), `axisLength`, `orientation`, `tickLocator`,
974
+ `tickFormatter`, `extend` and the extend triangles (`colorbar.color.extend-min` / `-max`).
975
+
976
+ `fp.tag_seaborn(ax, plot="heatmap")` (and the bivariate `histplot` / `kdeplot`) turn a seaborn
977
+ mesh or contour set into the same kind of field, cells named and colour key linked.
978
+
979
+ ### Colormap and palette collections
980
+
981
+ Every colormap and palette fluxplot knows is plain data in the package —
982
+ `src/fluxplot/definitions/colormaps.json` and `palettes.json` — so nothing beyond
983
+ matplotlib is imported at runtime and Flux bundles the very same files for its pickers:
984
+
985
+ | `fx.maps` collection | what | maps |
986
+ | --- | --- | --- |
987
+ | `flexoki` | fluxplot's own maps (`flexoki_diverging`, …) | 5 |
988
+ | `mpl` | matplotlib's built-ins, in its documented groups | 86 |
989
+ | `crameri` | Fabio Crameri's Scientific colour maps | 60 |
990
+ | `tol` | Paul Tol's continuous and discrete maps | 20 |
991
+ | `cmasher` | cmasher | 57 |
992
+
993
+ Every shipped map is registered with matplotlib under its qualified name and its
994
+ reverse from `import fluxplot` on, so `cmap="crameri.batlow"` (or `"tol.sunset_r"`)
995
+ works in any matplotlib call; `fx.maps.get("batlow")` resolves a bare name through the
996
+ collections in that order, `fx.maps.names("crameri")` lists a collection and
997
+ `fx.maps.info("tol.sunset")` tells you its type (`sequential` / `diverging` / `cyclic` /
998
+ `qualitative` / `misc`), family, table size `N`, whether it is `discrete` (a listed map of at
999
+ most `fx.DISCRETE_MAX` = 32 colours — a set of classes to pick from) and whether it is
1000
+ perceptually `uniform` (`True` / `False` / `None` = not assessed). Each definition's `colors`
1001
+ is the map's exact lookup table, so a consumer reproduces it colour for colour.
1002
+
1003
+ The house maps are the `flexoki` collection — `flexoki.sequential`, `.warm`, `.diverging`,
1004
+ `.terrain`, `.spectrum`, linear ramps through palette anchors (`fx.FLEXOKI_MAP_ANCHORS`) and
1005
+ therefore *not* uniform (`info()["uniform"] is False`); their historical bare names
1006
+ (`flexoki_diverging`) are aliases of the same tables.
1007
+
1008
+ Two derived maps: `fx.maps.truncate("batlow", 0.2, 0.8)` is the stretch of a map as a map of its
1009
+ own, named `crameri.batlow[0.2:0.8]` — a name `fx.maps.get` (and so a recipe) resolves again —
1010
+ and `fx.maps.discretize("viridis", [0, 2, 5, 10], extend="both")` returns a `(ListedColormap,
1011
+ BoundaryNorm)` pair, one colour per bin, that any helper takes as `cmap=` / `norm=` (the colour
1012
+ scale is then recorded as `kind: "binned"`).
1013
+
1014
+ In a signature plot's `palette=`, a colour *name* is the colour (`"red"` → a pale → red → deep
1015
+ ramp of one hue); the 13-step Flexoki ramp is `"flexoki.red"`.
1016
+
1017
+ Palettes live beside them under `fx.palettes`: `flexoki` (the default), `brewer`
1018
+ (ColorBrewer — 9-class sequential, 11-class diverging and the qualitative sets) and
1019
+ `tol` (Paul Tol's colour sets). `fx.palettes.brewer["Blues"]` and
1020
+ `fx.palettes.get("tol", "bright")` are lists of hex strings.
1021
+
1022
+ The JSON is built by `tools/build_color_definitions.py` from the upstream packages
1023
+ (matplotlib, cmcrameri, tol-colors, cmasher) — a build-time input only, re-run when a
1024
+ collection should be refreshed (`uv run --with cmcrameri --with tol-colors python
1025
+ tools/build_color_definitions.py`; without a package its shipped definition is kept). The
1026
+ Flexoki palette itself has one canonical source, the design-token export
1027
+ `src/fluxplot/definitions/flexoki.tokens.json`: `fx.flex` is built from it at import and the
1028
+ `flexoki` palette collection is generated from it (fluxplot's "green" is the tokens' custom
1029
+ `GRN` hue; Flexoki's original green is `olive`).
1030
+
1031
+ Every `fx.use_*` theme installs the house sequential map (`cmasher.rainforest`, `fx.SEQUENTIAL`)
1032
+ as matplotlib's default `image.cmap`, so a heatmap without `cmap=` is in the house style;
1033
+ `fx.DEFAULT_DIVERGING` names the diverging default.
1034
+
1035
+ ### Themes a consumer can follow
1036
+
1037
+ The manifest records the theme in force at save — `style = {"theme": "light" | "dark" | …, "tokens":
1038
+ {"ink", "label", "tick", "axis", "grid", "plot", "paper"}}`, the scaffold colours by role as
1039
+ lowercase hex (`theme` is `null` once an rcParam was changed by hand) — and every scaffold element
1040
+ painted with one of them carries `data-ink-fill` / `data-ink-stroke` naming the token: tick labels
1041
+ and titles `ink`, axis titles `label`, ticks `tick`, spines `axis`, gridlines `grid`, the axes and
1042
+ figure backgrounds (`axes.background`, `figure.background`, now parts of their own) `plot` and
1043
+ `paper`. Overlays a helper drew in the theme's ink (the significance bracket) say so outright.
1044
+ Data colours are never tagged, so Flux can restyle a light plot's furniture onto a dark deck and
1045
+ leave the science alone. `fp.save(..., theme_vars=True)` additionally rewrites those paints to
1046
+ `var(--fx-<token>, <hex>)` for a CSS-aware host (off by default until checked in Illustrator and
1047
+ Inkscape; browsers and rsvg honour the fallback). A rerun with
1048
+ `FLUX_PARAMS={"__fluxplot__": {"theme": "dark"}}` makes every `fx.use_*` call apply that theme,
1049
+ and the recipe records the theme in force under `__fluxplot__.theme`.
1050
+
1051
+ ### Series colours, and the same colour for the same thing everywhere
1052
+
1053
+ Every series records its primary paint in the manifest — `series[].color = {"hex": "#35ab49",
1054
+ "alpha": 1.0, "token": "flexoki.green-400", "palette": {"name": "flexoki.light", "index": 2}}`:
1055
+ the exact palette token that names it when one does, its slot in the active cycle when it came
1056
+ from there (`"varies"` for a colour-mapped collection, whose `scale` names the colour scale).
1057
+ The glowbar and fluxbox payloads record the `palette` spec each category's shades came from.
1058
+
1059
+ `fp.colors.categories` keeps categories consistent across figures: `get("SD")` returns the colour
1060
+ pinned to a category, else assigns the next unused slot of the palette (default: the theme's
1061
+ cycle) in first-request order and remembers it, so `"b"` is the same colour whether or not `"a"`
1062
+ is on the plot — `categorical_colors` (surface maps), glowbar and fluxbox group colours and
1063
+ `tag_seaborn`'s hue levels all consult it. `assign({...})` pins colours, `save()` / `load()`
1064
+ write and read a project file, `fluxplot.colors.json`
1065
+ (`{"spec": "fluxplot/colors", "version": 1, "categories": {"SD": "#bc5215"}, "palette": "flexoki"}`),
1066
+ which the first use auto-loads from `$FLUXPLOT_COLORS` or the nearest one between the working
1067
+ directory and the Git root; nothing is ever written implicitly. `categories.auto_series = True`
1068
+ colours `fp.line` / `fp.scatter` series by their name when the call gives no colour (off by
1069
+ default). Two more recipe controls follow: `__fluxplot__.palette = "tol.bright"` makes every
1070
+ `fx.use_*` install that palette as the prop cycle (and glowbar categories draw from it), and
1071
+ `__fluxplot__.series = {"<series id>": {"color": "#…"}}` recolours a series on a rerun — the
1072
+ helpers and the signature plots check it before drawing.
1073
+
1074
+ ### Signature plots on dark grounds
1075
+
1076
+ The glowbar, fluxbox and hexmatrix judge every ink against the ground they are drawn on. On a
1077
+ dark theme the point rims lighten instead of darkening, the shades stay at least 20 lightness
1078
+ units above the ground, the mean and median inks lift off their marks instead of deepening
1079
+ (`_colour.median_ink`, shared by both plots), connectors take the theme's grid neutral, a
1080
+ qualitative palette's group ink is the light neutral, and a hexmatrix's single-colour ramp turns
1081
+ (deep near the ground → pale at the top, named `hexmatrix.mono-dark:#…` so a rerun rebuilds it).
1082
+ `tests/test_dark_ground.py` pins WCAG contrast ≥ 1.5 for every mark and ≥ 3 for bracket and
1083
+ identity lines under the light, paper and dark themes.
1084
+
1085
+ ### Value × confidence
1086
+
1087
+ A colour-mapped mark can carry a second, opacity channel: `alpha_by=` on `fp.hexmatrix`
1088
+ (`"count"`, a column, a per-point or per-hexagon array, or a matrix in matrix mode), `fp.heatmap`
1089
+ (a matrix of the same shape — p-values, n) and `fp.scatter` (one value per point), with
1090
+ `alpha_range=(0.25, 1.0)` and `alpha_norm="linear"|"log"`. Each element's alpha runs over the
1091
+ range with its value (a missing value takes the low end), set as per-element alpha so matplotlib
1092
+ renders it; the manifest records the channel as `colorScales[].alpha = {source, range, norm}` and
1093
+ every element carries `data-alpha-value`, so a consumer can recompute `fill-opacity` live. A
1094
+ hexmatrix mean map where few observations fall, or a correlation matrix with non-significant cells
1095
+ washed out (`alpha_by=p_values, alpha_norm="log"`), is one keyword.
1096
+
1097
+ `fp.hexmatrix` also keeps its hexagons individually addressable up to `vector_limit=5000` (a
1098
+ per-mark raster threshold; `None` falls back to the save's generic 800), and on axes that share x
1099
+ or y it leaves the box aspect unlocked with a warning instead of distorting the siblings.
1100
+
1101
+ ### Accessibility lint
1102
+
1103
+ `fp.colorcheck` asks what a plot's colours do for the reader: `simulate(colors, "deuteranomaly")`
1104
+ applies the Machado (2009) colour-vision-deficiency matrices (embedded at every published
1105
+ severity; protanomaly / deuteranomaly / tritanomaly), `greyscale` shows the black-and-white print,
1106
+ `delta_e` is CIEDE2000 and `contrast` the WCAG 2 ratio. `check_palette(colors, bg, text=…)` finds
1107
+ pairs that collapse under a deficiency (`cvd-confusable`, ΔE < 10) or in greyscale
1108
+ (`greyscale-confusable`, ΔL* < 10) and marks (`low-contrast-mark`, < 3:1) or text
1109
+ (`low-contrast-text`, < 4.5:1) too faint against the ground; `check_colormap` flags a map whose
1110
+ ΔE steps vary too much (`non-uniform`) or a sequential map whose lightness reverses
1111
+ (`non-monotone`). `fp.save(..., lint="warn")` runs `check_figure` on the tagged series and text
1112
+ inks: findings go to `SaveResult.warnings` and the manifest's `quality.color`; `lint="error"`
1113
+ refuses the save. Off by default.
1114
+
1115
+ Measured with it, the house cycles were reordered (2026-09-30) so that no two *adjacent* colours
1116
+ collapse for a colour-deficient reader: `fx.CYCLE_ORDER` is now blue, orange, purple, green,
1117
+ magenta, yellow, cyan, red (the smallest adjacent distance under any deficiency is ΔE 20 in the
1118
+ light cycle, 15 in the dark one; cyan next to magenta used to be 5.6). Plots that let the cycle
1119
+ pick their colours get different colours from the third series on. Greyscale separation cannot
1120
+ be fixed by order alone at one weight — vary marker or line style for a print-safe figure.
1121
+
1122
+ ### Colour controls in the recipe
1123
+
1124
+ Every colour scale is a recipe control: `fp.save` writes its complete state under
1125
+ `recipe.params.__fluxplot__[<key>]`, and Flux's **Color scales** editor edits it and regenerates:
1126
+
1127
+ ```jsonc
1128
+ {"cmap": "magma" | {"lut": ["#…"], "under": "#…", "over": "#…", "bad": "#…"},
1129
+ "reversed": false,
1130
+ "vmin": 1, "vmax": 50,
1131
+ "norm": {"kind": "linear" | "log" | "symlog" | "power" | "twoslope" | "centered",
1132
+ "vcenter": 0, "gamma": 0.5, "linthresh": 1, "linscale": 1},
1133
+ "extend": "neither" | "min" | "max" | "both"}
1134
+ ```
1135
+
1136
+ The helpers read the controls from `FLUX_PARAMS` automatically. A control that merely restates
1137
+ what the script asked for (the replay of a recorded save) keeps the script's own objects — a
1138
+ `ListedColormap` with no resolvable name included — so regeneration never fails on its own
1139
+ output; a control that differs is applied, and an impossible one (an unknown map, a log norm with
1140
+ `vmin <= 0`, a twoslope centre outside the limits) raises a `ValueError` naming the key.
1141
+ `extend` reaches the colour key drawn with `fp.colorbar`. The old flat `{cmap, vmin, vmax}` is
1142
+ still understood.
1143
+
1144
+ The key names the **series** (`rates`), never the axes' position, so adding a subplot cannot
1145
+ orphan an edit; a second colour-mapped series with the same name gets `panel.<name>.rates` or
1146
+ `axes.<n>.rates`, and `key=` names it explicitly. Controls saved by an older Flux under the
1147
+ positional key are still honoured. `recipe={"args": ..., "cwd": ..., "output": ...}` overrides
1148
+ are honored; cwd/output resolve relative to the recipe directory. `FLUXPLOT_ONLY` skips
1149
+ unselected saves before layout or file I/O.
1150
+
1151
+ #### One scale for several panels
1152
+
1153
+ Declare the scale once and let every panel join it:
1154
+
1155
+ ```python
1156
+ fp.color_scale("corr", cmap="RdBu_r", center=0) # norm="linear"|"log"|"sqrt"|"symlog", vmin/vmax, robust
1157
+ fp.heatmap(ax1, C1, series="ctrl", scale="corr")
1158
+ fp.heatmap(ax2, C2, series="drug", scale="corr") # also contour/contourf, scatter(c=…), hexmatrix
1159
+ fp.colorbar(scale="corr", ax=[ax1, ax2], label="r") # one key, borrowing space from both panels
1160
+ ```
1161
+
1162
+ The members share the map and the norm, and their limits default to the **union** of every
1163
+ member's finite values (symmetric about `center` when one is given; `robust=True` uses the
1164
+ 2nd–98th percentiles), resolved at `fp.save` before layout so the figure never lays out against
1165
+ stale limits. The manifest carries one `colorScales` entry (`id: "corr"`) listing every member and
1166
+ the key, the recipe carries one control (`__fluxplot__.corr`), and an edit to it — a new map, a
1167
+ pinned `vmax` — repaints every panel at once.
1168
+
1169
+ `force_vectors=True` overrides and restores caller rasterization flags, including axes z-order
1170
+ rasterization. It cannot vectorize a source image created with `imshow`; use a modest
1171
+ `cells=True` heatmap when individual vector cells are needed. Empty/clipped raster artists no
1172
+ longer disrupt another layer's IDs. Surface lighting survives SVG rendering and colorbars use
1173
+ a separate scalar mappable. Surface category rendering uses per-part painter ordering, **not a
1174
+ full cross-category depth buffer**: convex projections are supported, while arbitrary folded
1175
+ meshes can have cross-part occlusion differences. This limitation is recorded in surface metadata.
1176
+
1177
+ Old saved projects remain readable. Schema 0.3 output should be used with the accompanying Flux
1178
+ reader update. New imports and reloads verify SVG checksums before legacy geometry repair;
1179
+ a mismatched pair leaves the last accepted plot intact. No existing project is bulk-regenerated.
1180
+
1181
+ ### 3D fluxplots
1182
+
1183
+ `fp.scene3d()` holds an actual mesh with named parts, value fields and shape states:
1184
+
1185
+ ```python
1186
+ sc = fp.scene3d(figsize=(3.5, 3), units='µm', axes='triad')
1187
+ fp.mesh3d(sc, (vertices, faces), series='cell')
1188
+ sc.view(azimuth=30, elevation=20)
1189
+ fp.save(sc, 'plots/cell') # GLB + semantic manifest + regeneration recipe
1190
+ ```
1191
+
1192
+ Scenes display interactively in trusted notebooks with a PNG fallback. Flux can choose
1193
+ another angle, restyle parts and remap fields without rerunning Python. See the
1194
+ [3D guide](docs/SCENE3D.md) for shape states, same-topology morphs, supported mesh inputs,
1195
+ optional decimation and a self-contained neuron demo.
1196
+
1197
+ ## License
1198
+
1199
+ MIT (see `LICENSE`). The bundled colour data comes from Flexoki, matplotlib, ColorBrewer, Crameri's Scientific colour maps, Paul Tol and cmasher under their own permissive licenses; see `THIRD_PARTY_NOTICES.md`.