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,412 @@
|
|
|
1
|
+
"""Colour sources shared by the signature fluxplots.
|
|
2
|
+
|
|
3
|
+
A signature plot takes *one* colour spec per group and turns it into (a) as many distinct point
|
|
4
|
+
colours as it needs and (b) one solid, representative group colour. The spec can be anything
|
|
5
|
+
fluxplot knows about:
|
|
6
|
+
|
|
7
|
+
* a colormap name from ``fp.colors.maps`` — qualified (``"cmasher.emerald"``, ``"crameri.batlow"``,
|
|
8
|
+
``"tol.sunset"``, ``"cmr.emerald"``) or bare (``"emerald"``, ``"YlGnBu"``), ``_r`` for reversed;
|
|
9
|
+
* a palette from ``fp.colors.palettes`` — ``"brewer.Set2"``, ``"tol.bright"``, ``"flexoki.blue"`` or a
|
|
10
|
+
bare group name (``"bright"``) when no colormap or colour has that name;
|
|
11
|
+
* any matplotlib-registered colormap name or a ``Colormap`` object;
|
|
12
|
+
* a list of colours (a hand-made palette);
|
|
13
|
+
* a single colour (it becomes a pale → colour → deep ramp). A colour name wins over a palette
|
|
14
|
+
of the same name: ``"red"`` is the colour, ``"flexoki.red"`` the 13-step Flexoki ramp.
|
|
15
|
+
|
|
16
|
+
**Ordered** sources (sequential maps / palettes — lightness runs one way) are oriented light → dark;
|
|
17
|
+
points take equal perceptual steps between two lightness bounds and the group colour sits at a fixed
|
|
18
|
+
position along the ramp. **Unordered** continuous sources (diverging, cyclic, rainbow) are sampled at
|
|
19
|
+
equal perceptual steps along the part of the map inside the lightness bounds — so a diverging map's
|
|
20
|
+
pale centre is skipped, never used for a dot on white. **Qualitative** palettes keep their designer's
|
|
21
|
+
order (their first colours are the most distinct), minus any too pale to read. For unordered sources
|
|
22
|
+
the group colour is the map's most chromatic colour of mid lightness — its most characteristic hue.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Optional
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
#: lightness window used when choosing a representative colour of an unordered source
|
|
32
|
+
_REP_LIGHTNESS = (35.0, 70.0)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# perceptual space
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
def lab(rgb) -> np.ndarray:
|
|
39
|
+
"""sRGB in [0, 1] (…, 3+) → CIELAB (D65)."""
|
|
40
|
+
c = np.asarray(rgb, float)[..., :3]
|
|
41
|
+
lin = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
|
|
42
|
+
m = np.array([[0.4124564, 0.3575761, 0.1804375],
|
|
43
|
+
[0.2126729, 0.7151522, 0.0721750],
|
|
44
|
+
[0.0193339, 0.1191920, 0.9503041]])
|
|
45
|
+
xyz = lin @ m.T / np.array([0.95047, 1.0, 1.08883])
|
|
46
|
+
f = np.where(xyz > 216 / 24389, np.cbrt(xyz), (24389 / 27 * xyz + 16) / 116)
|
|
47
|
+
return np.stack([116 * f[..., 1] - 16, 500 * (f[..., 0] - f[..., 1]),
|
|
48
|
+
200 * (f[..., 1] - f[..., 2])], axis=-1)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def perceptual(rgb) -> np.ndarray:
|
|
52
|
+
"""sRGB → a perceptually uniform space whose first axis is lightness on a 0–100 scale.
|
|
53
|
+
|
|
54
|
+
CAM02-UCS (the space viridis and cmasher are designed in) when ``colorspacious`` is importable —
|
|
55
|
+
it ships with cmasher, a fluxplot dependency — otherwise CIELAB.
|
|
56
|
+
"""
|
|
57
|
+
rgb = np.asarray(rgb, float)[..., :3]
|
|
58
|
+
try:
|
|
59
|
+
from colorspacious import cspace_convert
|
|
60
|
+
except ImportError: # pragma: no cover - colorspacious comes with cmasher
|
|
61
|
+
return lab(rgb)
|
|
62
|
+
return cspace_convert(rgb, "sRGB1", "CAM02-UCS")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def darken(c, k):
|
|
66
|
+
from matplotlib.colors import to_rgba
|
|
67
|
+
r, g, b, a = to_rgba(c)
|
|
68
|
+
return (r * (1 - k), g * (1 - k), b * (1 - k), a)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _monotone(rgbs, tol=1.0) -> bool:
|
|
72
|
+
"""Lightness runs one way (within ``tol``) — i.e. the colours form an ordered ramp."""
|
|
73
|
+
L = lab(np.asarray(rgbs, float))[..., 0]
|
|
74
|
+
d = np.diff(L)
|
|
75
|
+
return bool(len(L) >= 3 and (np.all(d <= tol) or np.all(d >= -tol)))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# resolving a spec
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
@dataclass
|
|
82
|
+
class ColourSource:
|
|
83
|
+
"""One group's colour source, resolved."""
|
|
84
|
+
|
|
85
|
+
label: str
|
|
86
|
+
#: "continuous" (sample a colormap) or "discrete" (pick from a list of colours)
|
|
87
|
+
kind: str
|
|
88
|
+
#: lightness runs one way (sequential); ordered sources are oriented light → dark
|
|
89
|
+
ordered: bool
|
|
90
|
+
cmap: Optional[object] = None # continuous sources
|
|
91
|
+
colours: list = field(default_factory=list) # discrete sources, RGBA
|
|
92
|
+
fixed: Optional[tuple] = None # a single colour given as the spec: it IS the group colour
|
|
93
|
+
brewer: bool = False # a ColorBrewer ramp (its 0.75 tone is the designed group colour)
|
|
94
|
+
|
|
95
|
+
def ramp(self):
|
|
96
|
+
"""A light→dark colormap for an ordered source (discrete lists are interpolated)."""
|
|
97
|
+
from matplotlib.colors import LinearSegmentedColormap
|
|
98
|
+
if self.cmap is not None:
|
|
99
|
+
return self.cmap
|
|
100
|
+
return LinearSegmentedColormap.from_list(f"glowbar-{self.label}", [c[:3] for c in self.colours])
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _from_colormap(cm, label, kind_hint=None, discrete_hint=None):
|
|
104
|
+
from matplotlib.colors import ListedColormap
|
|
105
|
+
|
|
106
|
+
from ..colors import DISCRETE_MAX
|
|
107
|
+
discrete = discrete_hint if discrete_hint is not None else (
|
|
108
|
+
isinstance(cm, ListedColormap) and cm.N <= DISCRETE_MAX)
|
|
109
|
+
if discrete and hasattr(cm, "colors"):
|
|
110
|
+
return _from_list(list(cm.colors), label, kind_hint)
|
|
111
|
+
if kind_hint in ("sequential", "diverging", "cyclic", "qualitative"):
|
|
112
|
+
ordered = kind_hint == "sequential"
|
|
113
|
+
else:
|
|
114
|
+
ordered = _monotone(cm(np.linspace(0, 1, 64)))
|
|
115
|
+
if ordered and lab(cm(0.0))[0] < lab(cm(1.0))[0]:
|
|
116
|
+
cm = cm.reversed()
|
|
117
|
+
return ColourSource(label, "continuous", ordered, cmap=cm)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _rgba(c):
|
|
121
|
+
from matplotlib.colors import to_rgba
|
|
122
|
+
return to_rgba(c)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _from_list(colours, label, kind_hint=None):
|
|
126
|
+
cols = [_rgba(c) for c in colours]
|
|
127
|
+
if not cols:
|
|
128
|
+
raise ValueError(f"colour source {label!r} is an empty palette")
|
|
129
|
+
ordered = kind_hint == "sequential" if kind_hint else _monotone([c[:3] for c in cols])
|
|
130
|
+
if ordered and lab(cols[0])[0] < lab(cols[-1])[0]:
|
|
131
|
+
cols = cols[::-1]
|
|
132
|
+
return ColourSource(label, "discrete", ordered, colours=cols)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _palette(name):
|
|
136
|
+
"""``(colours, type)`` for ``"collection.group"`` or a bare group name, else ``None``."""
|
|
137
|
+
from .. import colors as _colors
|
|
138
|
+
pal = _colors.palettes
|
|
139
|
+
if "." in name:
|
|
140
|
+
cid, _, group = name.partition(".")
|
|
141
|
+
candidates = [(cid, group)] if cid in pal.collections() else []
|
|
142
|
+
else:
|
|
143
|
+
candidates = [(cid, name) for cid in ("brewer", "tol", "flexoki")]
|
|
144
|
+
for cid, group in candidates:
|
|
145
|
+
for g in pal.info(cid)["groups"]:
|
|
146
|
+
if g["name"] == group:
|
|
147
|
+
return [s["hex"] for s in g["swatches"]], g.get("type")
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def resolve(spec, label="group") -> ColourSource:
|
|
152
|
+
"""Resolve any colour spec fluxplot understands (see the module docstring)."""
|
|
153
|
+
from matplotlib.colors import Colormap
|
|
154
|
+
|
|
155
|
+
if isinstance(spec, Colormap):
|
|
156
|
+
info = _map_info(spec.name)
|
|
157
|
+
return _from_colormap(spec, label, info.get("type"), info.get("discrete"))
|
|
158
|
+
if isinstance(spec, str):
|
|
159
|
+
info = _map_info(spec)
|
|
160
|
+
src = _resolve_str(spec, label, info)
|
|
161
|
+
if src is not None:
|
|
162
|
+
src.brewer = _is_brewer(spec, info)
|
|
163
|
+
return src
|
|
164
|
+
elif isinstance(spec, (list, tuple)) and len(spec) and not _is_single_colour(spec):
|
|
165
|
+
return _from_list(list(spec), label)
|
|
166
|
+
return _single(spec, label)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _resolve_str(spec, label, info):
|
|
170
|
+
"""A named colormap or palette → its source, or ``None`` when the name is neither.
|
|
171
|
+
|
|
172
|
+
A string that names a single colour (``"red"``, ``"#4cb391"``, ``"tab:blue"``) is that
|
|
173
|
+
colour — the Flexoki ramp of the same name is spelled ``"flexoki.red"``.
|
|
174
|
+
"""
|
|
175
|
+
import matplotlib as mpl
|
|
176
|
+
from matplotlib.colors import is_color_like
|
|
177
|
+
|
|
178
|
+
from .. import colors as _colors
|
|
179
|
+
if is_color_like(spec) and spec not in mpl.colormaps:
|
|
180
|
+
return None # resolve() turns it into a single-colour ramp
|
|
181
|
+
if spec in mpl.colormaps: # matplotlib's exact map (built-ins, cmr.*, fluxplot-registered names)
|
|
182
|
+
return _from_colormap(mpl.colormaps[spec], label, info.get("type"), info.get("discrete"))
|
|
183
|
+
try: # fluxplot's collections: bare names ("emerald", "batlow"), "cmasher.emerald", …
|
|
184
|
+
return _from_colormap(_colors.maps.get(spec), label, info.get("type"), info.get("discrete"))
|
|
185
|
+
except KeyError:
|
|
186
|
+
pass
|
|
187
|
+
found = _palette(spec)
|
|
188
|
+
if found is not None:
|
|
189
|
+
colours, kind = found
|
|
190
|
+
return _from_list(colours, label, kind)
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
_BREWER = None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _is_brewer(spec, info):
|
|
198
|
+
global _BREWER
|
|
199
|
+
if _BREWER is None:
|
|
200
|
+
from .. import colors as _colors
|
|
201
|
+
_BREWER = set(_colors.palettes.names("brewer"))
|
|
202
|
+
base = spec[:-2] if spec.endswith("_r") else spec
|
|
203
|
+
cid, _, bare = base.rpartition(".")
|
|
204
|
+
return bare in _BREWER and cid in ("", "brewer", "mpl")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _single(spec, label):
|
|
208
|
+
import matplotlib as mpl
|
|
209
|
+
try:
|
|
210
|
+
colour = _rgba(spec)
|
|
211
|
+
except (ValueError, TypeError):
|
|
212
|
+
raise ValueError(
|
|
213
|
+
f"{label}: {spec!r} is not a colormap (fp.colors.maps / matplotlib), a palette "
|
|
214
|
+
"(fp.colors.palettes, e.g. 'brewer.Set2' or 'tol.bright'), a list of colours or a colour"
|
|
215
|
+
) from None
|
|
216
|
+
rgb = np.array(colour[:3])
|
|
217
|
+
ramp = [tuple(1 - 0.82 * (1 - rgb)), tuple(rgb), darken(colour, 0.6)[:3]]
|
|
218
|
+
src = _from_colormap(mpl.colors.LinearSegmentedColormap.from_list(f"glowbar-{label}", ramp),
|
|
219
|
+
label, "sequential")
|
|
220
|
+
src.fixed = colour
|
|
221
|
+
return src
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _is_single_colour(spec) -> bool:
|
|
225
|
+
try:
|
|
226
|
+
_rgba(spec)
|
|
227
|
+
return True
|
|
228
|
+
except (ValueError, TypeError):
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _map_info(name) -> dict:
|
|
233
|
+
from .. import colors as _colors
|
|
234
|
+
try:
|
|
235
|
+
return _colors.maps.info(name)
|
|
236
|
+
except Exception:
|
|
237
|
+
return {}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
# choosing colours
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
def even_shades(cmap, n, pale=88.0, dark=22.0):
|
|
244
|
+
"""``n`` colours from a light→dark colormap, spaced EVENLY IN PERCEIVED COLOUR.
|
|
245
|
+
|
|
246
|
+
The shades run from lightness ``pale`` to ``dark`` (0 = black, 100 = white) in equal steps of
|
|
247
|
+
CAM02-UCS distance, i.e. equal *visible* differences. Sampling a map evenly in its parameter
|
|
248
|
+
instead makes some neighbours nearly identical (ColorBrewer's YlGnBu merges its two darkest
|
|
249
|
+
navies that way). A single shade is taken from the middle of the range.
|
|
250
|
+
"""
|
|
251
|
+
if n <= 0:
|
|
252
|
+
return []
|
|
253
|
+
t = np.linspace(0.0, 1.0, 2048)
|
|
254
|
+
u = perceptual(cmap(t))
|
|
255
|
+
lightness = np.minimum.accumulate(u[:, 0]) # sequential maps darken monotonically; drop wiggles
|
|
256
|
+
keep = (lightness <= pale) & (lightness >= dark)
|
|
257
|
+
if keep.sum() < 2:
|
|
258
|
+
raise ValueError(
|
|
259
|
+
f"the colormap {getattr(cmap, 'name', cmap)!r} has no stretch between lightness "
|
|
260
|
+
f"{pale} and {dark}; widen shade_range")
|
|
261
|
+
arc = np.r_[0.0, np.cumsum(np.linalg.norm(np.diff(u[keep], axis=0), axis=1))]
|
|
262
|
+
targets = [arc[-1] / 2] if n == 1 else np.linspace(0.0, arc[-1], n)
|
|
263
|
+
return [cmap(float(ti)) for ti in np.interp(targets, arc, t[keep])]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _even_along(cmap, n, pale, dark):
|
|
267
|
+
"""Equal perceptual steps along the parts of ANY colormap inside the lightness window.
|
|
268
|
+
|
|
269
|
+
Jumps across excluded stretches (a diverging map's pale centre) add no distance, so the chosen
|
|
270
|
+
colours stay evenly spread over what is actually usable.
|
|
271
|
+
"""
|
|
272
|
+
t = np.linspace(0.0, 1.0, 2048)
|
|
273
|
+
u = perceptual(cmap(t))
|
|
274
|
+
keep = (u[:, 0] <= pale) & (u[:, 0] >= dark)
|
|
275
|
+
if keep.sum() < 2:
|
|
276
|
+
raise ValueError(f"the colormap {getattr(cmap, 'name', cmap)!r} has no stretch between "
|
|
277
|
+
f"lightness {pale} and {dark}; widen shade_range")
|
|
278
|
+
tk, uk = t[keep], u[keep]
|
|
279
|
+
step = np.linalg.norm(np.diff(uk, axis=0), axis=1)
|
|
280
|
+
step[np.diff(np.flatnonzero(keep)) > 1] = 0.0 # no distance across a gap
|
|
281
|
+
arc = np.r_[0.0, np.cumsum(step)]
|
|
282
|
+
targets = [arc[-1] / 2] if n == 1 else np.linspace(0.0, arc[-1], n)
|
|
283
|
+
return [cmap(float(tk[min(np.searchsorted(arc, a), len(tk) - 1)])) for a in targets]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def shades(src: ColourSource, n, pale=88.0, dark=22.0):
|
|
287
|
+
"""``n`` distinct point colours from a resolved source."""
|
|
288
|
+
if n <= 0:
|
|
289
|
+
return []
|
|
290
|
+
if src.ordered:
|
|
291
|
+
if src.kind == "discrete":
|
|
292
|
+
usable = [c for c in src.colours if dark <= perceptual(c[:3])[0] <= pale]
|
|
293
|
+
if len(usable) >= n: # the designer's own classes, evenly spread
|
|
294
|
+
idx = np.round(np.linspace(0, len(usable) - 1, n)).astype(int) if n > 1 else [len(usable) // 2]
|
|
295
|
+
return [usable[i] for i in idx]
|
|
296
|
+
return even_shades(src.ramp(), n, pale, dark)
|
|
297
|
+
if src.kind == "continuous":
|
|
298
|
+
return _even_along(src.cmap, n, pale, dark)
|
|
299
|
+
usable = [c for c in src.colours if perceptual(c[:3])[0] <= pale] or src.colours
|
|
300
|
+
if n > len(usable):
|
|
301
|
+
import warnings
|
|
302
|
+
warnings.warn(f"{src.label}: the palette has {len(usable)} usable colours for {n} points; "
|
|
303
|
+
"colours repeat — use a colormap (or a larger palette) to keep them distinct",
|
|
304
|
+
stacklevel=3)
|
|
305
|
+
return [usable[i % len(usable)] for i in range(n)]
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
#: neutral group colour for multi-hue sources on a light ground (Flexoki base-600): no single
|
|
309
|
+
#: hue stands for them
|
|
310
|
+
NEUTRAL = (0x6F / 255, 0x6E / 255, 0x69 / 255, 1.0)
|
|
311
|
+
#: its counterpart on a dark ground (Flexoki base-300)
|
|
312
|
+
NEUTRAL_DARK = (0xB7 / 255, 0xB5 / 255, 0xAC / 255, 1.0)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ---------------------------------------------------------------------------
|
|
316
|
+
# the ground: every ink is judged against what it is drawn on
|
|
317
|
+
# ---------------------------------------------------------------------------
|
|
318
|
+
def ground_lightness(ground) -> float:
|
|
319
|
+
"""Perceptual lightness (0–100) of a ground colour."""
|
|
320
|
+
return float(perceptual(np.array(_rgba(ground)[:3]))[0])
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def is_dark(ground) -> bool:
|
|
324
|
+
"""A dark ground (lightness below 50): inks lift toward white instead of deepening."""
|
|
325
|
+
return ground_lightness(ground) < 50.0
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def neutral_for(ground=None):
|
|
329
|
+
"""The neutral ink for a multi-hue source: base-600 on a light ground, base-300 on a dark one."""
|
|
330
|
+
return NEUTRAL_DARK if ground is not None and is_dark(ground) else NEUTRAL
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def rim(col, ground=None, k=0.3):
|
|
334
|
+
"""A point's outline: a deeper shade of its own colour on a light ground, a lighter one on a
|
|
335
|
+
dark ground — so the palest and the deepest points both stay crisp against the ground."""
|
|
336
|
+
from matplotlib.colors import to_rgba
|
|
337
|
+
r, g, b, a = to_rgba(col)
|
|
338
|
+
if ground is not None and is_dark(ground):
|
|
339
|
+
return (r + (1 - r) * k, g + (1 - g) * k, b + (1 - b) * k, a)
|
|
340
|
+
return darken(col, k)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def shade_bounds(pale: float, dark: float, ground=None) -> tuple:
|
|
344
|
+
"""The lightness window for a group's shades on ``ground``: as given on a light ground; on a
|
|
345
|
+
dark ground the dark bound is lifted to at least 20 lightness units above the ground, so the
|
|
346
|
+
deepest shade still stands off it (the "lift instead of deepen" rule)."""
|
|
347
|
+
if ground is None or not is_dark(ground):
|
|
348
|
+
return pale, dark
|
|
349
|
+
return pale, max(dark, min(pale - 10.0, ground_lightness(ground) + 20.0))
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def median_ink(col, alpha, ground, contrast):
|
|
353
|
+
"""The group colour, deepened — or, on a ground darker than it, lifted — only as far as it takes
|
|
354
|
+
to stand ``contrast`` lightness units (0–100) off the group's mark as rendered over ``ground``
|
|
355
|
+
at opacity ``alpha`` (the fluxbox's box wash, the glowbar's glow peak)."""
|
|
356
|
+
from matplotlib.colors import to_rgba
|
|
357
|
+
rgb, bg = np.array(to_rgba(col)[:3]), np.array(to_rgba(ground)[:3])
|
|
358
|
+
box = perceptual(alpha * rgb + (1 - alpha) * bg)[0]
|
|
359
|
+
k = np.linspace(0.0, 1.0, 101)[:, None]
|
|
360
|
+
toward = 0.0 if perceptual(rgb)[0] <= box else 1.0 # the mark is paler than its ink on a light ground
|
|
361
|
+
inks = rgb + (toward - rgb) * k # the ink → black (or → white), in 1 % steps
|
|
362
|
+
far = np.abs(perceptual(inks)[:, 0] - box) >= contrast
|
|
363
|
+
return (*inks[int(np.argmax(far)) if far.any() else -1], 1.0)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def representative(src: ColourSource, position=None, ground=None):
|
|
367
|
+
"""One solid colour that stands for the whole source (glow, caps, the mean's base shade).
|
|
368
|
+
|
|
369
|
+
* a single colour given as the spec → that colour;
|
|
370
|
+
* ``position`` given → that point of the light → dark ramp (ordered sources);
|
|
371
|
+
* a ColorBrewer ramp → 0.75 along it (Brewer places a strong, saturated tone there);
|
|
372
|
+
* any other source → its most chromatic colour of mid lightness, provided its hues agree
|
|
373
|
+
(one hue family, e.g. cmasher ``emerald``); a source whose hues spread around the wheel
|
|
374
|
+
(diverging, rainbow) has no honest single hue, and a qualitative palette is a set of
|
|
375
|
+
different categories — both get a neutral ink, chosen against ``ground``.
|
|
376
|
+
"""
|
|
377
|
+
neutral = neutral_for(ground)
|
|
378
|
+
if src.fixed is not None:
|
|
379
|
+
return src.fixed
|
|
380
|
+
if src.ordered and (position is not None or src.brewer):
|
|
381
|
+
return tuple(src.ramp()(0.75 if position is None else position))
|
|
382
|
+
if src.kind == "discrete" and not src.ordered:
|
|
383
|
+
return neutral # a qualitative palette: every colour is a different category, none is 'the' hue
|
|
384
|
+
cands = list(src.cmap(np.linspace(0, 1, 256))) if src.kind == "continuous" else list(src.colours)
|
|
385
|
+
u = perceptual(np.array([c[:3] for c in cands]))
|
|
386
|
+
chroma = np.hypot(u[:, 1], u[:, 2])
|
|
387
|
+
mid = (u[:, 0] >= _REP_LIGHTNESS[0]) & (u[:, 0] <= _REP_LIGHTNESS[1]) & (chroma > 5)
|
|
388
|
+
if not mid.any():
|
|
389
|
+
return neutral
|
|
390
|
+
hue = np.arctan2(u[mid, 2], u[mid, 1])
|
|
391
|
+
w = chroma[mid]
|
|
392
|
+
resultant = np.hypot((w * np.cos(hue)).sum(), (w * np.sin(hue)).sum()) / w.sum()
|
|
393
|
+
if resultant < 0.6: # hues spread around the wheel
|
|
394
|
+
return neutral
|
|
395
|
+
pool = np.flatnonzero(mid)
|
|
396
|
+
return tuple(cands[int(pool[np.argmax(chroma[pool])])])
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def interleaved_order(n):
|
|
400
|
+
"""Lane → shade rank such that neighbouring lanes are always ≥ 2 shades apart.
|
|
401
|
+
|
|
402
|
+
``0, h, 1, h+1, …`` with ``h = ceil(n/2)`` — e.g. ``[0, 3, 1, 4, 2, 5]`` for six lanes. The palest
|
|
403
|
+
shade still sits in the first lane and the darkest in the last.
|
|
404
|
+
"""
|
|
405
|
+
import itertools
|
|
406
|
+
h = (n + 1) // 2
|
|
407
|
+
order = []
|
|
408
|
+
for a, b in itertools.zip_longest(range(h), range(h, n)):
|
|
409
|
+
order.append(a)
|
|
410
|
+
if b is not None:
|
|
411
|
+
order.append(b)
|
|
412
|
+
return order
|