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
fluxplot/colors.py ADDED
@@ -0,0 +1,727 @@
1
+ """fluxplot.colors — the canonical fluxplot-flexoki palette and colormap registry.
2
+
3
+ Almost all color "defaults" in fluxplot come from here; :mod:`fluxplot.style` (and
4
+ everything else) imports its color definitions from this module, so an edit here
5
+ percolates everywhere.
6
+
7
+ Usage::
8
+
9
+ from fluxplot import colors as fx
10
+
11
+ fx.green400 # the flexoki palette is available at the top level (hex string)
12
+ fx.flex.get("green-400") # full metadata dict (h, l, hex, rgb)
13
+
14
+ fx.maps.emerald # the cmasher "emerald" colormap
15
+ fx.maps.flexoki_diverging # a fluxplot custom colormap
16
+ fx.maps.view_map_set("cmasher") # plot a labelled grid of every map in a set
17
+
18
+ Anything that isn't the flexoki palette needs a specifier — ``fx.maps`` for
19
+ colormaps, and eventually e.g. ``fx.tol`` if we add Paul Tol's colors.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import matplotlib as mpl
25
+ from matplotlib.colors import Colormap, LinearSegmentedColormap, ListedColormap
26
+ import functools
27
+ import json
28
+ from importlib import resources
29
+
30
+ __all__ = ["flex", "maps", "palettes", "categories", "token_of", "palette_of", "palette_colors", "DISCRETE_MAX"]
31
+
32
+ #: A ``ListedColormap`` with at most this many colours is a *discrete* map (a set of classes to
33
+ #: pick from); above it, a listed map is treated as a continuous ramp. One threshold for the
34
+ #: colour-scale records, the signature plots' palette resolution and the definitions builder.
35
+ DISCRETE_MAX = 32
36
+
37
+
38
+ class _FlexPalette:
39
+ """Flexoki color palette with convenient attribute access.
40
+
41
+ Attribute names use the pattern ``{color}{level}`` (no separator),
42
+ e.g. ``green300``, ``red600``, ``base500``, ``paper``, ``black``.
43
+
44
+ Accessing an attribute returns the **hex string** by default.
45
+ Use :meth:`get` to retrieve the full metadata dict for a color.
46
+ """
47
+
48
+ def __init__(self, data: dict[str, dict]) -> None:
49
+ self._data = data
50
+
51
+ # -- attribute access (returns hex) --------------------------------
52
+ def __getattr__(self, name: str) -> str:
53
+ key = _attr_to_key(name)
54
+ if key in self._data:
55
+ return self._data[key]["hex"]
56
+ raise AttributeError(f"No color {name!r} in the Flexoki palette")
57
+
58
+ def __dir__(self) -> list[str]:
59
+ extras = [_key_to_attr(k) for k in self._data]
60
+ return sorted(set(super().__dir__()) | set(extras))
61
+
62
+ # -- dict-style access (returns full metadata) ---------------------
63
+ def get(self, name: str) -> dict:
64
+ """Return the full metadata dict (h, l, hex, rgb) for a color.
65
+
66
+ *name* can be either attribute-style (``green300``) or
67
+ key-style (``green-300``).
68
+ """
69
+ key = _attr_to_key(name) if "-" not in name else name
70
+ if key not in self._data:
71
+ raise KeyError(f"No color {name!r} in the Flexoki palette")
72
+ return self._data[key]
73
+
74
+ def __repr__(self) -> str:
75
+ return f"FlexPalette({len(self._data)} colors)"
76
+
77
+ def __contains__(self, name: str) -> bool:
78
+ key = _attr_to_key(name) if "-" not in name else name
79
+ return key in self._data
80
+
81
+ def keys(self) -> list[str]:
82
+ """Return all color keys (dash-separated form)."""
83
+ return list(self._data.keys())
84
+
85
+
86
+ def _attr_to_key(attr: str) -> str:
87
+ """Convert attribute name to dict key: ``green300`` -> ``green-300``."""
88
+ # Find the split point: last alpha char before first digit
89
+ for i, ch in enumerate(attr):
90
+ if ch.isdigit():
91
+ return f"{attr[:i]}-{attr[i:]}"
92
+ return attr # no digits (e.g. "paper", "black")
93
+
94
+
95
+ def _key_to_attr(key: str) -> str:
96
+ """Convert dict key to attribute name: ``green-300`` -> ``green300``."""
97
+ return key.replace("-", "")
98
+
99
+
100
+ # The Flexoki palette. The canonical definition is the design-token export
101
+ # ``definitions/flexoki.tokens.json`` (the W3C design-tokens format Figma writes); this table is
102
+ # built from it at import. Two house conventions ride on top: fluxplot's "green" is the custom
103
+ # hue the tokens file stores under ``GRN`` (Flexoki's own green is kept as "olive"), and
104
+ # ``base-0`` / ``base-1000`` alias ``paper`` / ``black``. ``definitions/palettes.json``'s Flexoki
105
+ # collection is generated from this table by ``tools/build_color_definitions.py``; the three
106
+ # copies can never disagree (``tests/test_color_definitions.py::test_single_source``).
107
+ _HUE_OF_GROUP = {"base": "k", "red": "r", "orange": "o", "yellow": "y", "olive": "ol", "GRN": "g",
108
+ "cyan": "c", "blue": "b", "purple": "p", "magenta": "m"}
109
+
110
+
111
+ @functools.lru_cache(maxsize=None)
112
+ def _definitions(name: str) -> dict:
113
+ with resources.files("fluxplot").joinpath(f"definitions/{name}.json").open("r", encoding="utf-8") as f:
114
+ return json.load(f)
115
+
116
+
117
+ def _rgb(hex_: str) -> tuple:
118
+ return tuple(int(hex_[i:i + 2], 16) for i in (1, 3, 5))
119
+
120
+
121
+ def _load_flexoki_tokens() -> dict:
122
+ tokens = _definitions("flexoki.tokens")
123
+ data: dict[str, dict] = {}
124
+ for group, hue in _HUE_OF_GROUP.items():
125
+ entries = {}
126
+ for name, token in tokens.get(group, {}).items():
127
+ if not isinstance(token, dict) or "$value" not in token or name.endswith("-opacity-10"):
128
+ continue
129
+ hex_ = str(token["$value"]["hex"]).upper()
130
+ tail = name.rsplit("-", 1)[-1]
131
+ level = int(tail) if tail.isdigit() else {"paper": 0, "black": 1000}.get(name, 0)
132
+ entries[name] = {"h": hue, "l": level, "hex": hex_, "rgb": _rgb(hex_)}
133
+ if group == "base": # paper, base-0 (= paper), base-50 … base-950, base-1000 (= black), black
134
+ levels = sorted((e for n, e in entries.items() if n.startswith("base-")), key=lambda e: e["l"])
135
+ data["paper"] = entries["paper"]
136
+ data["base-0"] = dict(entries["paper"])
137
+ for e in levels:
138
+ data[f"base-{e['l']}"] = e
139
+ data["base-1000"] = dict(entries["black"])
140
+ data["black"] = entries["black"]
141
+ else:
142
+ for e in sorted(entries.values(), key=lambda e: e["l"]):
143
+ data[f"{group if group != 'GRN' else 'green'}-{e['l']}"] = e
144
+ return data
145
+
146
+
147
+ _flex_data = _load_flexoki_tokens()
148
+
149
+ flex = _FlexPalette(_flex_data)
150
+
151
+ # -------------------------------------------
152
+ # MAPS SECTION
153
+ # -------------------------------------------
154
+
155
+
156
+ # -------------------------------------------
157
+ # DEFINITIONS (JSON, shipped with the package)
158
+ # -------------------------------------------
159
+ #
160
+ # Every colormap and palette collection fluxplot knows lives in
161
+ # ``definitions/colormaps.json`` and ``definitions/palettes.json`` as plain data —
162
+ # matplotlib's maps, Fabio Crameri's Scientific colour maps, Paul Tol's maps and
163
+ # sets, cmasher, ColorBrewer, Flexoki. The files are built once by
164
+ # ``tools/build_color_definitions.py`` from the upstream packages; at runtime
165
+ # nothing but the JSON is read, so no upstream package is a dependency, and Flux
166
+ # bundles the very same files for its pickers. A continuous map is 256 samples,
167
+ # a discrete one its exact colours.
168
+
169
+ _MAP_COLLECTION_ORDER = ("mpl", "crameri", "tol", "cmasher")
170
+
171
+
172
+ def _build_cmap(full_name: str, m: dict) -> Colormap:
173
+ """Rebuild a shipped map from its definition. ``colors`` is the map's exact lookup table:
174
+ a listed map's own colours (``N`` of them, ``discrete`` when few enough to be classes), or
175
+ 256 samples of a continuous map — which, resampled through ``from_list`` at ``N=256``, is
176
+ byte for byte the table matplotlib indexes."""
177
+ colors = list(m["colors"])
178
+ if m.get("discrete") or m.get("N", 256) != 256 or len(colors) != 256:
179
+ return ListedColormap(colors, name=full_name)
180
+ return LinearSegmentedColormap.from_list(full_name, colors, N=256)
181
+
182
+
183
+ class _MapRegistry:
184
+ """Colormap access under ``fx.maps``.
185
+
186
+ Attribute lookup resolves fluxplot's custom maps first (``maps.flexoki_diverging``,
187
+ and eventually e.g. ``maps.fluxglow``), then the shipped collections (``maps.emerald``
188
+ -> ``cmasher.emerald``, ``maps.batlow`` -> ``crameri.batlow``), and finally the
189
+ `cmasher <https://cmasher.readthedocs.io>`_ package itself (a dependency), for any map
190
+ newer than the shipped definitions.
191
+ """
192
+
193
+ def __init__(self) -> None:
194
+ self._custom: dict[str, Colormap] = {}
195
+ self._alias_of: dict[str, str] = {} # "flexoki_diverging" -> "flexoki.diverging"
196
+ # collection id -> {bare name: Colormap}; built from the JSON once
197
+ self._collections: dict[str, dict[str, Colormap]] | None = None
198
+ self._info: dict[str, dict] = {}
199
+
200
+ # -- the JSON collections --------------------------------------------
201
+ def _ensure(self) -> dict[str, dict[str, Colormap]]:
202
+ if self._collections is None:
203
+ cols: dict[str, dict[str, Colormap]] = {}
204
+ for c in _definitions("colormaps")["collections"]:
205
+ cols[c["id"]] = {}
206
+ for m in c["maps"]:
207
+ full = f"{c['id']}.{m['name']}"
208
+ cm = _build_cmap(full, m)
209
+ cols[c["id"]][m["name"]] = cm
210
+ self._info[full] = {
211
+ "collection": c["id"], "name": m["name"], "type": m["type"],
212
+ "family": m.get("family"), "discrete": bool(m.get("discrete")),
213
+ "uniform": m.get("uniform"), "N": int(m.get("N", len(m["colors"]))),
214
+ }
215
+ for variant in (cm, cm.reversed()):
216
+ try:
217
+ mpl.colormaps.register(variant)
218
+ except Exception: # already registered — best-effort
219
+ pass
220
+ self._collections = cols
221
+ return self._collections
222
+
223
+ def collections(self) -> list[str]:
224
+ """The map collections: ``'flexoki'`` (fluxplot's own) then the shipped ones."""
225
+ return list(dict.fromkeys(["flexoki", *self._ensure()]))
226
+
227
+ def get(self, name: str) -> Colormap:
228
+ """Resolve a colormap by name: ``'crameri.batlow'``, a bare ``'batlow'`` (the
229
+ first collection that has it — matplotlib, Crameri, Tol, cmasher), a fluxplot
230
+ custom map, any of those with ``_r`` for the reversed map, or a truncation
231
+ ``'batlow[0.2:0.8]'`` (see :meth:`truncate`)."""
232
+ if name in self._custom:
233
+ return self._custom[name]
234
+ if name.endswith("]") and "[" in name:
235
+ base_name, _, span = name[:-1].rpartition("[")
236
+ lo, _, hi = span.partition(":")
237
+ try:
238
+ return self.truncate(base_name, float(lo), float(hi))
239
+ except ValueError:
240
+ raise KeyError(f"No colormap {name!r}: a truncation is spelled 'name[lo:hi]' with 0 <= lo < hi <= 1") from None
241
+ base, reversed_ = (name[:-2], True) if name.endswith("_r") else (name, False)
242
+ cols = self._ensure()
243
+ found: Colormap | None = None
244
+ if "." in base:
245
+ cid, _, bare = base.partition(".")
246
+ if cid == "cmr":
247
+ cid = "cmasher"
248
+ found = cols.get(cid, {}).get(bare)
249
+ else:
250
+ if base in self._custom:
251
+ found = self._custom[base]
252
+ else:
253
+ for cid in _MAP_COLLECTION_ORDER:
254
+ if base in cols.get(cid, {}):
255
+ found = cols[cid][base]
256
+ break
257
+ if found is None:
258
+ raise KeyError(f"No colormap {name!r} in fluxplot's collections ({', '.join(self.collections())})")
259
+ return found.reversed() if reversed_ else found
260
+
261
+ def info(self, name: str) -> dict:
262
+ """Collection, type (sequential / diverging / cyclic / qualitative / misc), family,
263
+ discreteness, table size ``N`` and perceptual uniformity (``True`` / ``False`` /
264
+ ``None`` = not assessed) of a shipped map (``'crameri.batlow'`` or bare)."""
265
+ cm = self.get(name)
266
+ key = cm.name[:-2] if cm.name.endswith("_r") else cm.name
267
+ key = key.split("[", 1)[0] # a truncation reports its source map
268
+ found = self._info.get(key) or self._info.get(self._alias_of.get(key, ""))
269
+ return dict(found or {"collection": "flexoki", "name": key, "type": "custom", "family": None,
270
+ "discrete": isinstance(cm, ListedColormap) and cm.N <= DISCRETE_MAX,
271
+ "uniform": None, "N": int(cm.N)})
272
+
273
+ # -- derived maps -----------------------------------------------------
274
+ def truncate(self, cmap, lo: float, hi: float, n: int = 256) -> Colormap:
275
+ """The stretch ``[lo, hi]`` of a map as a map of its own, named ``"<name>[lo:hi]"`` —
276
+ which :meth:`get` resolves again, so a recipe can record it."""
277
+ if not (0.0 <= lo < hi <= 1.0):
278
+ raise ValueError(f"truncate: need 0 <= lo < hi <= 1, got {lo!r}, {hi!r}")
279
+ cm = cmap if isinstance(cmap, Colormap) else self._resolve_any(cmap)
280
+ import numpy as np
281
+ name = f"{cm.name}[{lo:g}:{hi:g}]"
282
+ return LinearSegmentedColormap.from_list(name, cm(np.linspace(lo, hi, n)), N=n)
283
+
284
+ def discretize(self, cmap, boundaries=None, *, n: int | None = None, vmin=None, vmax=None,
285
+ extend: str = "neither"):
286
+ """A binned scale: ``(ListedColormap, BoundaryNorm)`` with one colour per bin. Give the
287
+ ``boundaries`` (bin edges), or ``n`` bins between ``vmin`` and ``vmax``. ``extend``
288
+ adds under / over colours from the map's ends. Helpers accept the norm as ``norm=``;
289
+ the colour scale is then recorded as ``kind: "binned"``."""
290
+ import numpy as np
291
+ from matplotlib.colors import BoundaryNorm
292
+ cm = cmap if isinstance(cmap, Colormap) else self._resolve_any(cmap)
293
+ if boundaries is None:
294
+ if n is None or vmin is None or vmax is None:
295
+ raise ValueError("discretize: give boundaries, or n with vmin and vmax")
296
+ boundaries = np.linspace(vmin, vmax, int(n) + 1)
297
+ b = np.asarray(boundaries, dtype=float)
298
+ if b.ndim != 1 or b.size < 2 or not np.all(np.diff(b) > 0):
299
+ raise ValueError("discretize: boundaries must be at least two increasing numbers")
300
+ if extend not in ("neither", "min", "max", "both"):
301
+ raise ValueError("discretize: extend must be neither, min, max or both")
302
+ bins = b.size - 1
303
+ extra = (extend in ("min", "both")) + (extend in ("max", "both"))
304
+ # with extend, matplotlib's BoundaryNorm reserves the map's first / last colour for the
305
+ # under / over region (a Colormap's default under / over ARE its end entries), so the
306
+ # listed map carries bins + extensions colours and the norm counts all of them
307
+ colours = cm(np.linspace(0, 1, bins + extra))
308
+ listed = ListedColormap(colours, name=f"{cm.name}[{bins} bins{'+' + extend if extra else ''}]")
309
+ listed.set_bad(cm.get_bad())
310
+ return listed, BoundaryNorm(b, bins + extra, extend=extend)
311
+
312
+ def _resolve_any(self, name: str) -> Colormap:
313
+ if name in mpl.colormaps:
314
+ return mpl.colormaps[name]
315
+ return self.get(name)
316
+
317
+ # -- attribute access -----------------------------------------------
318
+ def __getattr__(self, name: str) -> Colormap:
319
+ if name.startswith("_"):
320
+ raise AttributeError(name)
321
+ if name in self._custom:
322
+ return self._custom[name]
323
+ try:
324
+ return self.get(name)
325
+ except KeyError:
326
+ pass
327
+ cmr = _import_cmasher()
328
+ if cmr is None: # pragma: no cover - cmasher is a dependency
329
+ raise AttributeError(f"No colormap {name!r}: not a fluxplot custom map, and cmasher is unavailable")
330
+ cm = getattr(cmr, name, None)
331
+ if isinstance(cm, Colormap):
332
+ return cm
333
+ raise AttributeError(f"No colormap {name!r} in the fluxplot custom maps or cmasher")
334
+
335
+ def __dir__(self) -> list[str]:
336
+ names = set(super().__dir__()) | set(self._custom)
337
+ for maps in self._ensure().values():
338
+ names |= set(maps)
339
+ return sorted(names)
340
+
341
+ def __repr__(self) -> str:
342
+ n = sum(len(m) for m in self._ensure().values())
343
+ return f"MapRegistry({len(self._custom)} custom maps + {n} shipped in {', '.join(_MAP_COLLECTION_ORDER)})"
344
+
345
+ # -- registration ----------------------------------------------------
346
+ def register(self, cmap: Colormap) -> None:
347
+ """Add a custom colormap (also registered with matplotlib, so
348
+ ``plt.imshow(..., cmap=cmap.name)`` works by name), together with its reversed
349
+ twin under ``<name>_r``."""
350
+ for variant in (cmap, cmap.reversed()):
351
+ self._custom[variant.name] = variant
352
+ try:
353
+ mpl.colormaps.register(variant)
354
+ except Exception: # already registered / older mpl — best-effort
355
+ pass
356
+
357
+ # -- map sets ----------------------------------------------------------
358
+ def names(self, set_name: str = "cmasher") -> list[str]:
359
+ """Return the colormap names in a collection: ``'flexoki'``, ``'mpl'``,
360
+ ``'crameri'``, ``'tol'`` or ``'cmasher'`` (reversed ``*_r`` variants omitted)."""
361
+ return [name for name, _ in self._map_set(set_name)]
362
+
363
+ def view_map_set(self, set_name: str = "cmasher", ncols: int = 3):
364
+ """Plot a labelled grid of every colormap in *set_name*, for quick browsing.
365
+
366
+ ``set_name`` is ``'cmasher'`` (reversed ``*_r`` variants omitted) or
367
+ ``'flexoki'`` (the fluxplot custom maps). Returns the figure.
368
+ """
369
+ import numpy as np
370
+ import matplotlib.pyplot as plt
371
+
372
+ entries = self._map_set(set_name)
373
+ nrows = -(-len(entries) // ncols)
374
+ fig, axes = plt.subplots(
375
+ nrows,
376
+ ncols,
377
+ figsize=(ncols * 3.2, 0.62 * nrows + 0.5),
378
+ squeeze=False,
379
+ layout="constrained",
380
+ )
381
+ gradient = np.linspace(0, 1, 256)[None, :]
382
+ for ax, (name, cm) in zip(axes.flat, entries):
383
+ ax.imshow(gradient, aspect="auto", cmap=cm)
384
+ ax.set_title(name, loc="left", fontsize=7, family="monospace", pad=2)
385
+ for ax in axes.flat:
386
+ ax.set_axis_off()
387
+ fig.suptitle(f"{set_name} colormaps", fontsize=10)
388
+ return fig
389
+
390
+ def _map_set(self, set_name: str) -> list[tuple[str, Colormap]]:
391
+ s = set_name.lower()
392
+ cols = self._ensure()
393
+ if s in ("flexoki", "fluxplot", "flux"):
394
+ shipped = [(f"flexoki.{n}", cm) for n, cm in cols.get("flexoki", {}).items()]
395
+ custom = [(n, cm) for n, cm in self._custom.items() if not n.endswith("_r") and n not in self._alias_of]
396
+ return shipped + sorted(custom)
397
+ if s in cols:
398
+ return list(cols[s].items())
399
+ raise ValueError(
400
+ f"Unknown map set {set_name!r}; available sets: {', '.join(self.collections())}"
401
+ )
402
+
403
+
404
+ def _import_cmasher():
405
+ try:
406
+ import cmasher # importing also registers "cmr.*" names in matplotlib
407
+
408
+ return cmasher
409
+ except ImportError:
410
+ return None
411
+
412
+
413
+ maps = _MapRegistry()
414
+ maps._ensure() # every shipped map is addressable by name in matplotlib from `import fluxplot` on
415
+
416
+
417
+ # -------------------------------------------
418
+ # PALETTES SECTION
419
+ # -------------------------------------------
420
+
421
+
422
+ class _Palettes:
423
+ """Palette collections under ``fx.palettes``: ``'flexoki'`` (the default),
424
+ ``'brewer'`` (ColorBrewer) and ``'tol'`` (Paul Tol's colour sets), from
425
+ ``definitions/palettes.json``. ``palettes.brewer["Blues"]`` is a list of hex
426
+ strings; ``palettes.get("tol", "bright")`` the same; ``palettes.info("brewer")``
427
+ the collection's metadata and typed groups."""
428
+
429
+ def collections(self) -> list[str]:
430
+ return [c["id"] for c in _definitions("palettes")["collections"]]
431
+
432
+ def info(self, collection: str) -> dict:
433
+ for c in _definitions("palettes")["collections"]:
434
+ if c["id"] == collection:
435
+ return c
436
+ raise KeyError(f"No palette collection {collection!r}; available: {', '.join(self.collections())}")
437
+
438
+ def names(self, collection: str) -> list[str]:
439
+ return [g["name"] for g in self.info(collection)["groups"]]
440
+
441
+ def get(self, collection: str, group: str) -> list[str]:
442
+ for g in self.info(collection)["groups"]:
443
+ if g["name"] == group:
444
+ return [s["hex"] for s in g["swatches"]]
445
+ raise KeyError(f"No group {group!r} in palette collection {collection!r}")
446
+
447
+ def __getattr__(self, collection: str) -> dict[str, list[str]]:
448
+ if collection.startswith("_"):
449
+ raise AttributeError(collection)
450
+ try:
451
+ return {g["name"]: [s["hex"] for s in g["swatches"]] for g in self.info(collection)["groups"]}
452
+ except KeyError as e:
453
+ raise AttributeError(str(e)) from None
454
+
455
+ def __dir__(self) -> list[str]:
456
+ return sorted(set(super().__dir__()) | set(self.collections()))
457
+
458
+ def __repr__(self) -> str:
459
+ return f"Palettes({', '.join(self.collections())})"
460
+
461
+
462
+ palettes = _Palettes()
463
+
464
+ # Flexoki-flavoured house maps: linear ramps through palette anchors (pleasant, NOT perceptually
465
+ # uniform — ``maps.info(...)["uniform"] is False``; prefer the cmasher / Crameri maps when
466
+ # uniformity matters). The anchors are the source ``tools/build_color_definitions.py`` samples
467
+ # into the ``flexoki`` collection of ``definitions/colormaps.json``; at runtime the shipped
468
+ # collection is used and each map is aliased under its historical bare name (``flexoki_diverging``
469
+ # == ``flexoki.diverging``), so both spellings resolve to the same table.
470
+ FLEXOKI_MAP_ANCHORS = {
471
+ # name: (type, [token names, light → dark or end → end])
472
+ "sequential": ("sequential", ["paper", "blue-150", "blue-400", "blue-600", "blue-800"]),
473
+ "warm": ("sequential", ["paper", "yellow-400", "orange-400", "red-600", "red-850"]),
474
+ "diverging": ("diverging", ["blue-600", "blue-400", "paper", "red-400", "red-600"]),
475
+ "terrain": ("sequential", ["blue-800", "blue-600", "cyan-400", "olive-400", "yellow-400", "orange-600", "paper"]),
476
+ "spectrum": ("cyclic", ["red-600", "orange-600", "yellow-600", "olive-600", "green-600", "cyan-600",
477
+ "blue-600", "purple-600", "magenta-600", "red-600"]),
478
+ }
479
+
480
+
481
+ def flexoki_map_from_anchors(name: str) -> Colormap:
482
+ """The house map ``name`` built straight from its palette anchors (what the builder samples)."""
483
+ kind, tokens = FLEXOKI_MAP_ANCHORS[name]
484
+ return LinearSegmentedColormap.from_list(f"flexoki.{name}", [flex.get(t)["hex"] for t in tokens])
485
+
486
+
487
+ def _install_house_maps() -> None:
488
+ shipped = maps._ensure().get("flexoki", {})
489
+ for name in FLEXOKI_MAP_ANCHORS:
490
+ source = shipped.get(name) or flexoki_map_from_anchors(name) # the JSON, or the anchors on a fresh build
491
+ alias = source.copy()
492
+ alias.name = f"flexoki_{name}"
493
+ maps.register(alias)
494
+ maps._alias_of[alias.name] = f"flexoki.{name}"
495
+ if f"flexoki.{name}" not in maps._info:
496
+ maps._info[f"flexoki.{name}"] = {"collection": "flexoki", "name": name, "type": FLEXOKI_MAP_ANCHORS[name][0],
497
+ "family": None, "discrete": False, "uniform": False, "N": source.N}
498
+
499
+
500
+ _install_house_maps()
501
+
502
+
503
+ # -------------------------------------------
504
+ # Naming a colour: the exact reverse lookup over every palette fluxplot ships
505
+ # -------------------------------------------
506
+ @functools.lru_cache(maxsize=None)
507
+ def _token_table() -> dict:
508
+ """lowercase hex → the first token that names it: ``flexoki.green-400``, ``brewer.Blues-3``,
509
+ ``tol.bright.blue`` (Flexoki first, then the shipped palette collections)."""
510
+ table: dict[str, str] = {}
511
+ for key, meta in _flex_data.items():
512
+ if key in ("base-0", "base-1000"):
513
+ continue
514
+ table.setdefault(meta["hex"].lower(), f"flexoki.{key}")
515
+ for c in _definitions("palettes")["collections"]:
516
+ if c["id"] == "flexoki":
517
+ continue
518
+ for g in c["groups"]:
519
+ for sw in g["swatches"]:
520
+ name = sw["name"] if c["id"] == "brewer" else f"{g['name']}.{sw['name']}"
521
+ table.setdefault(sw["hex"].lower(), f"{c['id']}.{name}")
522
+ return table
523
+
524
+
525
+ def token_of(color) -> str | None:
526
+ """The palette token an exact colour is (``colors.green400`` → ``"flexoki.green-400"``),
527
+ or ``None`` for a colour no shipped palette has. Alpha is ignored."""
528
+ from matplotlib.colors import to_hex
529
+ try:
530
+ return _token_table().get(to_hex(color).lower())
531
+ except ValueError:
532
+ return None
533
+
534
+
535
+ def palette_of(color, cycle=None) -> dict | None:
536
+ """``{"name", "index"}`` when ``color`` sits in the active prop cycle (``cycle`` = the list
537
+ of cycle colours, default matplotlib's): ``flexoki.light`` / ``flexoki.dark`` for the house
538
+ cycles, a shipped palette's name when the cycle is one, else ``"cycle"``."""
539
+ from matplotlib.colors import to_hex
540
+ if cycle is None:
541
+ cycle = [c["color"] for c in mpl.rcParams["axes.prop_cycle"]]
542
+ try:
543
+ hexes = [to_hex(c).lower() for c in cycle]
544
+ target = to_hex(color).lower()
545
+ except ValueError:
546
+ return None
547
+ if target not in hexes:
548
+ return None
549
+ from . import style as _style
550
+ if hexes == [h.lower() for h in _style.CYCLE_LIGHT]:
551
+ name = "flexoki.light"
552
+ elif hexes == [h.lower() for h in _style.CYCLE_DARK]:
553
+ name = "flexoki.dark"
554
+ else:
555
+ name = "cycle"
556
+ for c in _definitions("palettes")["collections"]:
557
+ for g in c["groups"]:
558
+ if [sw["hex"].lower() for sw in g["swatches"]] == hexes:
559
+ name = f"{c['id']}.{g['name']}"
560
+ return {"name": name, "index": hexes.index(target)}
561
+
562
+
563
+ # -------------------------------------------
564
+ # Category colours: one registry, so "SD" is the same colour in every figure
565
+ # -------------------------------------------
566
+ CATEGORIES_FILENAME = "fluxplot.colors.json"
567
+
568
+
569
+ class _Categories:
570
+ """Stable colours for named categories (``fp.colors.categories``).
571
+
572
+ ``get(name)`` returns the colour pinned to ``name``, else assigns the next unused slot of the
573
+ palette (default: the active theme's cycle) in first-request order and remembers it — so a
574
+ category keeps its colour across every figure of a session, whatever other categories each
575
+ figure shows. ``assign`` pins colours outright. ``load`` / ``save`` read and write a project
576
+ file (``fluxplot.colors.json``: ``{"spec": "fluxplot/colors", "version": 1, "categories":
577
+ {"SD": "#bc5215"}, "palette": "flexoki"}``); the first use auto-loads ``$FLUXPLOT_COLORS`` or
578
+ the nearest ``fluxplot.colors.json`` between the working directory and the Git root. Nothing
579
+ is ever written implicitly. ``auto_series=True`` makes ``fp.line`` / ``fp.scatter`` colour a
580
+ series by its name when no colour is given (off by default: it would change existing plots).
581
+ """
582
+
583
+ def __init__(self) -> None:
584
+ self._pinned: dict[str, str] = {}
585
+ self._assigned: dict[str, str] = {}
586
+ self._palette = None
587
+ self._loaded = False
588
+ self.auto_series = False
589
+ self.path: str | None = None
590
+
591
+ # -- state -----------------------------------------------------------
592
+ def reset(self) -> None:
593
+ self.__init__()
594
+
595
+ def assign(self, mapping: dict) -> None:
596
+ """Pin colours: ``{"SD": "#bc5215", "Sleep": colors.blue600}``."""
597
+ from matplotlib.colors import to_hex
598
+ for name, colour in mapping.items():
599
+ self._pinned[str(name)] = to_hex(colour, keep_alpha=False).lower()
600
+
601
+ def is_pinned(self, name) -> bool:
602
+ self._autoload()
603
+ return str(name) in self._pinned
604
+
605
+ def pinned(self) -> dict:
606
+ self._autoload()
607
+ return dict(self._pinned)
608
+
609
+ def _cycle(self, palette) -> list:
610
+ from matplotlib.colors import to_hex
611
+ spec = palette if palette is not None else self._palette
612
+ if spec is None:
613
+ return [to_hex(c["color"]).lower() for c in mpl.rcParams["axes.prop_cycle"]]
614
+ if isinstance(spec, (list, tuple)):
615
+ return [to_hex(c).lower() for c in spec]
616
+ return [h.lower() for h in palette_colors(spec)]
617
+
618
+ def get(self, name, *, palette=None) -> str:
619
+ """The colour for ``name``: pinned, remembered, or the next free slot of ``palette``."""
620
+ self._autoload()
621
+ key = str(name)
622
+ if key in self._pinned:
623
+ return self._pinned[key]
624
+ if key in self._assigned:
625
+ return self._assigned[key]
626
+ cycle = self._cycle(palette)
627
+ used = set(self._pinned.values()) | set(self._assigned.values())
628
+ free = [c for c in cycle if c not in used]
629
+ colour = free[0] if free else cycle[len(self._assigned) % len(cycle)]
630
+ self._assigned[key] = colour
631
+ return colour
632
+
633
+ # -- the project file --------------------------------------------------
634
+ def _autoload(self) -> None:
635
+ if self._loaded:
636
+ return
637
+ self._loaded = True
638
+ import os
639
+ path = os.environ.get("FLUXPLOT_COLORS") or _find_upwards(CATEGORIES_FILENAME)
640
+ if path:
641
+ self.load(path)
642
+
643
+ def load(self, path=None) -> str | None:
644
+ """Read a ``fluxplot.colors.json`` (default: the auto-discovered one); returns its path."""
645
+ import os
646
+ path = path or os.environ.get("FLUXPLOT_COLORS") or _find_upwards(CATEGORIES_FILENAME)
647
+ if not path or not os.path.exists(path):
648
+ return None
649
+ with open(path, "r", encoding="utf-8") as f:
650
+ doc = json.load(f)
651
+ if doc.get("spec") != "fluxplot/colors":
652
+ raise ValueError(f"{path}: not a fluxplot/colors file")
653
+ self._loaded = True
654
+ self.path = path
655
+ self.assign(doc.get("categories", {}))
656
+ self._palette = doc.get("palette") or None
657
+ return path
658
+
659
+ def save(self, path=None) -> str:
660
+ """Write the pinned colours (and the palette) as a ``fluxplot.colors.json``."""
661
+ import os
662
+ path = path or self.path or os.path.join(os.getcwd(), CATEGORIES_FILENAME)
663
+ doc = {"spec": "fluxplot/colors", "version": 1, "categories": dict(sorted(self._pinned.items())),
664
+ "palette": self._palette if isinstance(self._palette, str) else "flexoki"}
665
+ with open(path, "w", encoding="utf-8") as f:
666
+ json.dump(doc, f, indent=2, sort_keys=True)
667
+ f.write("\n")
668
+ self.path = path
669
+ return path
670
+
671
+ def __repr__(self) -> str:
672
+ return f"Categories({len(self._pinned)} pinned, {len(self._assigned)} assigned, file={self.path!r})"
673
+
674
+
675
+ def _find_upwards(filename: str) -> str | None:
676
+ """``filename`` in the working directory or an ancestor, stopping at the Git root."""
677
+ import os
678
+ here = os.getcwd()
679
+ while True:
680
+ cand = os.path.join(here, filename)
681
+ if os.path.exists(cand):
682
+ return cand
683
+ if os.path.isdir(os.path.join(here, ".git")):
684
+ return None
685
+ parent = os.path.dirname(here)
686
+ if parent == here:
687
+ return None
688
+ here = parent
689
+
690
+
691
+ def palette_colors(spec) -> list:
692
+ """The colours of a palette spec: ``"tol.bright"``, ``"brewer.Set2"``, ``"flexoki"`` (the
693
+ light house cycle), ``"flexoki.dark"``, or a bare group name searched in every collection."""
694
+ from . import style as _style
695
+ if spec in ("flexoki", "flexoki.light"):
696
+ return list(_style.CYCLE_LIGHT)
697
+ if spec == "flexoki.dark":
698
+ return list(_style.CYCLE_DARK)
699
+ if "." in spec:
700
+ cid, _, group = spec.partition(".")
701
+ return palettes.get(cid, group)
702
+ for cid in ("tol", "brewer", "flexoki"):
703
+ try:
704
+ return palettes.get(cid, spec)
705
+ except KeyError:
706
+ continue
707
+ raise KeyError(f"No palette {spec!r}; use 'collection.group' (fp.colors.palettes.collections())")
708
+
709
+
710
+ categories = _Categories()
711
+
712
+
713
+ # -------------------------------------------
714
+ # Module-level convenience: the flexoki palette at the top level
715
+ # -------------------------------------------
716
+ def __getattr__(name: str) -> str:
717
+ """``colors.green400`` -> the flexoki hex, without going through ``colors.flex``."""
718
+ try:
719
+ return getattr(flex, name)
720
+ except AttributeError:
721
+ raise AttributeError(
722
+ f"module {__name__!r} has no attribute {name!r}"
723
+ ) from None
724
+
725
+
726
+ def __dir__() -> list[str]:
727
+ return sorted(set(globals()) | {_key_to_attr(k) for k in _flex_data})