fluxplot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fluxplot/__init__.py +115 -0
- fluxplot/_fieldmap.py +97 -0
- fluxplot/_mesh_reduce.py +54 -0
- fluxplot/_scene3d_size.py +95 -0
- fluxplot/_viewer/THIRD-PARTY.txt +23 -0
- fluxplot/_viewer/flux-model3d-viewer.min.js +4221 -0
- fluxplot/_viewer/stamp.json +4 -0
- fluxplot/api.py +1196 -0
- fluxplot/autotag.py +164 -0
- fluxplot/base.mplstyle +0 -0
- fluxplot/brackets.py +242 -0
- fluxplot/canonical_json.py +23 -0
- fluxplot/capture.py +150 -0
- fluxplot/colorcheck.py +285 -0
- fluxplot/colors.py +727 -0
- fluxplot/colorscale.py +477 -0
- fluxplot/data.py +178 -0
- fluxplot/definitions/colormaps.json +1639 -0
- fluxplot/definitions/flexoki.tokens.json +2571 -0
- fluxplot/definitions/palettes.json +2547 -0
- fluxplot/descriptors.py +87 -0
- fluxplot/fields.py +611 -0
- fluxplot/fits.py +240 -0
- fluxplot/glb.py +84 -0
- fluxplot/ids.py +173 -0
- fluxplot/images.py +362 -0
- fluxplot/integrity.py +27 -0
- fluxplot/manifest.py +788 -0
- fluxplot/mesh3d.py +376 -0
- fluxplot/panels.py +284 -0
- fluxplot/postprocess.py +638 -0
- fluxplot/presets.py +66 -0
- fluxplot/provenance.py +177 -0
- fluxplot/raster.py +295 -0
- fluxplot/recipe.py +178 -0
- fluxplot/render.py +66 -0
- fluxplot/roles.py +147 -0
- fluxplot/scene3d.py +386 -0
- fluxplot/scene3d_manifest.py +112 -0
- fluxplot/scene3d_viewer.py +633 -0
- fluxplot/schemas/.gitkeep +0 -0
- fluxplot/schemas/manifest.schema.json +2479 -0
- fluxplot/schemas/recipe.schema.json +179 -0
- fluxplot/schemas/scene3d.schema.json +461 -0
- fluxplot/seaborn_adapters.py +323 -0
- fluxplot/signature_fluxplots/__init__.py +18 -0
- fluxplot/signature_fluxplots/_colour.py +412 -0
- fluxplot/signature_fluxplots/fluxbox.py +433 -0
- fluxplot/signature_fluxplots/glowbar.py +769 -0
- fluxplot/signature_fluxplots/hexmatrix.py +927 -0
- fluxplot/stats/__init__.py +63 -0
- fluxplot/stats/_common.py +196 -0
- fluxplot/stats/multi_group.py +443 -0
- fluxplot/stats/paired.py +209 -0
- fluxplot/stats/two_group.py +149 -0
- fluxplot/style.py +469 -0
- fluxplot/surface.py +487 -0
- fluxplot/surface3d.py +197 -0
- fluxplot/tagger.py +561 -0
- fluxplot/version.py +19 -0
- fluxplot-0.1.0.dist-info/METADATA +1199 -0
- fluxplot-0.1.0.dist-info/RECORD +65 -0
- fluxplot-0.1.0.dist-info/WHEEL +4 -0
- fluxplot-0.1.0.dist-info/licenses/LICENSE +21 -0
- fluxplot-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +472 -0
|
@@ -0,0 +1,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`.
|