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
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})
|