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/colorcheck.py ADDED
@@ -0,0 +1,285 @@
1
+ """Accessibility lint for colours — colour-vision deficiency, greyscale, contrast, colormap uniformity.
2
+
3
+ A plot's colours are chosen for the sighted author's screen. This module asks what happens to them
4
+ for the reader: **which pairs collapse** under the three common colour-vision deficiencies (Machado,
5
+ Oliveira & Fernandes 2009: 3×3 matrices in linear sRGB, embedded here at every published severity —
6
+ no extra dependency), **which vanish in greyscale** (a photocopied paper, a black-and-white print),
7
+ whether marks and text **contrast** enough with the ground (WCAG 2), and whether a colormap is
8
+ **perceptually uniform** (even ΔE steps, lightness running one way for a sequential map). Distances
9
+ are CIEDE2000 (``delta_e``) — the perceptual metric the CAM02-UCS helpers of the signature plots
10
+ approximate for their shade spacing.
11
+
12
+ :func:`check_palette` and :func:`check_colormap` return :class:`Finding` records; :func:`check_figure`
13
+ gathers a figure's series colours and text inks (the B2 colour records) and checks them against
14
+ the axes background. ``fp.save(..., lint="warn"|"error")`` runs it on the way out, puts the
15
+ findings in ``SaveResult.warnings`` and in the manifest's ``quality.color``.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import asdict, dataclass
20
+ from typing import List, Optional
21
+
22
+ import numpy as np
23
+
24
+ # ---------------------------------------------------------------------------------------------
25
+ # Machado, Oliveira & Fernandes (2009), "A Physiologically-based Model for Simulation of Color
26
+ # Vision Deficiency": the linear-sRGB matrices at severity 0.0 … 1.0 in steps of 0.1. Between
27
+ # published steps the matrices are interpolated linearly, as the paper's own tables are used.
28
+ # ---------------------------------------------------------------------------------------------
29
+ CVD_KINDS = ("protanomaly", "deuteranomaly", "tritanomaly")
30
+ _MACHADO = {
31
+ "protanomaly": [[[1.0, 0.0, -0.0], [0.0, 1.0, 0.0], [-0.0, -0.0, 1.0]], [[0.856167, 0.182038, -0.038205], [0.029342, 0.955115, 0.015544], [-0.00288, -0.001563, 1.004443]], [[0.734766, 0.334872, -0.069637], [0.05184, 0.919198, 0.028963], [-0.004928, -0.004209, 1.009137]], [[0.630323, 0.465641, -0.095964], [0.069181, 0.890046, 0.040773], [-0.006308, -0.007724, 1.014032]], [[0.539009, 0.579343, -0.118352], [0.082546, 0.866121, 0.051332], [-0.007136, -0.011959, 1.019095]], [[0.458064, 0.679578, -0.137642], [0.092785, 0.846313, 0.060902], [-0.007494, -0.016807, 1.024301]], [[0.38545, 0.769005, -0.154455], [0.100526, 0.829802, 0.069673], [-0.007442, -0.02219, 1.029632]], [[0.319627, 0.849633, -0.169261], [0.106241, 0.815969, 0.07779], [-0.007025, -0.028051, 1.035076]], [[0.259411, 0.923008, -0.18242], [0.110296, 0.80434, 0.085364], [-0.006276, -0.034346, 1.040622]], [[0.203876, 0.990338, -0.194214], [0.112975, 0.794542, 0.092483], [-0.005222, -0.041043, 1.046265]], [[0.152286, 1.052583, -0.204868], [0.114503, 0.786281, 0.099216], [-0.003882, -0.048116, 1.051998]]],
32
+ "deuteranomaly": [[[1.0, 0.0, -0.0], [0.0, 1.0, 0.0], [-0.0, 0.0, 1.0]], [[0.866435, 0.177704, -0.044139], [0.049567, 0.939063, 0.01137], [-0.003453, 0.007233, 0.99622]], [[0.760729, 0.319078, -0.079807], [0.090568, 0.889315, 0.020117], [-0.006027, 0.013325, 0.992702]], [[0.675425, 0.43385, -0.109275], [0.125303, 0.847755, 0.026942], [-0.00795, 0.018572, 0.989378]], [[0.605511, 0.52856, -0.134071], [0.155318, 0.812366, 0.032316], [-0.009376, 0.023176, 0.9862]], [[0.547494, 0.607765, -0.155259], [0.181692, 0.781742, 0.036566], [-0.01041, 0.027275, 0.983136]], [[0.498864, 0.674741, -0.173604], [0.205199, 0.754872, 0.039929], [-0.011131, 0.030969, 0.980162]], [[0.457771, 0.731899, -0.18967], [0.226409, 0.731012, 0.042579], [-0.011595, 0.034333, 0.977261]], [[0.422823, 0.781057, -0.203881], [0.245752, 0.709602, 0.044646], [-0.011843, 0.037423, 0.974421]], [[0.392952, 0.82361, -0.216562], [0.263559, 0.69021, 0.046232], [-0.01191, 0.040281, 0.97163]], [[0.367322, 0.860646, -0.227968], [0.280085, 0.672501, 0.047413], [-0.01182, 0.04294, 0.968881]]],
33
+ "tritanomaly": [[[1.0, 0.0, -0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], [[0.92667, 0.092514, -0.019184], [0.021191, 0.964503, 0.014306], [0.008437, 0.054813, 0.93675]], [[0.89572, 0.13333, -0.02905], [0.029997, 0.9454, 0.024603], [0.013027, 0.104707, 0.882266]], [[0.905871, 0.127791, -0.033662], [0.026856, 0.941251, 0.031893], [0.01341, 0.148296, 0.838294]], [[0.948035, 0.08949, -0.037526], [0.014364, 0.946792, 0.038844], [0.010853, 0.193991, 0.795156]], [[1.017277, 0.027029, -0.044306], [-0.006113, 0.958479, 0.047634], [0.006379, 0.248708, 0.744913]], [[1.104996, -0.046633, -0.058363], [-0.032137, 0.971635, 0.060503], [0.001336, 0.317922, 0.680742]], [[1.193214, -0.109812, -0.083402], [-0.058496, 0.97941, 0.079086], [-0.002346, 0.403492, 0.598854]], [[1.257728, -0.139648, -0.118081], [-0.078003, 0.975409, 0.102594], [-0.003316, 0.501214, 0.502102]], [[1.278864, -0.125333, -0.153531], [-0.084748, 0.957674, 0.127074], [-0.000989, 0.601151, 0.399838]], [[1.255528, -0.076749, -0.178779], [-0.078411, 0.930809, 0.147602], [0.004733, 0.691367, 0.3039]]],
34
+ }
35
+ _ALIASES = {"protanopia": "protanomaly", "deuteranopia": "deuteranomaly", "tritanopia": "tritanomaly",
36
+ "protan": "protanomaly", "deutan": "deuteranomaly", "tritan": "tritanomaly"}
37
+
38
+ #: Default thresholds of :func:`check_palette` / :func:`check_colormap`.
39
+ THRESHOLDS = {
40
+ "cvd-confusable": 10.0, # ΔE2000 between two palette colours under a simulation
41
+ "greyscale-confusable": 10.0, # ΔL* between two palette colours in greyscale
42
+ "low-contrast-mark": 3.0, # WCAG ratio of a mark against the ground
43
+ "low-contrast-text": 4.5, # WCAG ratio of text against the ground
44
+ "non-uniform": 0.35, # coefficient of variation of a colormap's ΔE steps
45
+ }
46
+
47
+
48
+ def machado_matrix(kind: str, severity: float = 1.0) -> np.ndarray:
49
+ """The 3×3 linear-sRGB matrix for ``kind`` at ``severity`` (0 = normal vision, 1 = dichromacy)."""
50
+ kind = _ALIASES.get(kind, kind)
51
+ if kind not in _MACHADO:
52
+ raise ValueError(f"unknown colour-vision deficiency {kind!r}; use one of {CVD_KINDS}")
53
+ if not 0.0 <= severity <= 1.0:
54
+ raise ValueError("severity must lie in [0, 1]")
55
+ table = np.asarray(_MACHADO[kind])
56
+ pos = severity * 10.0
57
+ lo = int(np.floor(pos))
58
+ hi = min(lo + 1, 10)
59
+ t = pos - lo
60
+ return (1 - t) * table[lo] + t * table[hi]
61
+
62
+
63
+ # ---------------------------------------------------------------------------------------------
64
+ # colour spaces
65
+ # ---------------------------------------------------------------------------------------------
66
+ def _rgb(colors) -> np.ndarray:
67
+ from matplotlib.colors import to_rgba_array
68
+ return to_rgba_array(list(colors) if not isinstance(colors, str) else [colors])[:, :3]
69
+
70
+
71
+ def _linear(rgb: np.ndarray) -> np.ndarray:
72
+ rgb = np.asarray(rgb, dtype=float)
73
+ return np.where(rgb <= 0.04045, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)
74
+
75
+
76
+ def _encode(lin: np.ndarray) -> np.ndarray:
77
+ lin = np.clip(np.asarray(lin, dtype=float), 0.0, 1.0)
78
+ return np.where(lin <= 0.0031308, lin * 12.92, 1.055 * np.power(lin, 1 / 2.4) - 0.055)
79
+
80
+
81
+ def lab(colors) -> np.ndarray:
82
+ """sRGB colours (anything matplotlib accepts, or an (N, 3) array in [0, 1]) → CIELAB (D65)."""
83
+ from .signature_fluxplots._colour import lab as _lab
84
+ return _lab(_rgb(colors) if not isinstance(colors, np.ndarray) or colors.ndim != 2 else colors)
85
+
86
+
87
+ def simulate(colors, kind: str, severity: float = 1.0) -> np.ndarray:
88
+ """What ``colors`` look like to a viewer with ``kind`` colour-vision deficiency, as sRGB in
89
+ [0, 1] (Machado et al. 2009, applied in linear sRGB). ``kind`` is ``protanomaly`` /
90
+ ``deuteranomaly`` / ``tritanomaly`` (or the ``-opia`` names); ``severity`` 1.0 is dichromacy."""
91
+ rgb = _rgb(colors) if not (isinstance(colors, np.ndarray) and colors.ndim == 2) else np.asarray(colors)[:, :3]
92
+ lin = _linear(rgb) @ machado_matrix(kind, severity).T
93
+ return _encode(lin)
94
+
95
+
96
+ def greyscale(colors) -> np.ndarray:
97
+ """The colours' lightness only (L* as an sRGB grey), the way a black-and-white print shows them."""
98
+ L = lab(colors)[:, 0]
99
+ fy = (L + 16) / 116
100
+ y = np.where(fy ** 3 > 216 / 24389, fy ** 3, (116 * fy - 16) * 27 / 24389)
101
+ grey = _encode(y)
102
+ return np.stack([grey, grey, grey], axis=-1)
103
+
104
+
105
+ def delta_e(a, b) -> float:
106
+ """CIEDE2000 colour difference between two colours (Sharma, Wu & Dalal 2005 formulation)."""
107
+ L1, a1, b1 = lab(a)[0]
108
+ L2, a2, b2 = lab(b)[0]
109
+ return float(_ciede2000(np.array([[L1, a1, b1]]), np.array([[L2, a2, b2]]))[0])
110
+
111
+
112
+ def _ciede2000(lab1: np.ndarray, lab2: np.ndarray) -> np.ndarray:
113
+ L1, a1, b1 = lab1[:, 0], lab1[:, 1], lab1[:, 2]
114
+ L2, a2, b2 = lab2[:, 0], lab2[:, 1], lab2[:, 2]
115
+ kL = kC = kH = 1.0
116
+ C1, C2 = np.hypot(a1, b1), np.hypot(a2, b2)
117
+ Cbar = (C1 + C2) / 2
118
+ G = 0.5 * (1 - np.sqrt(Cbar ** 7 / (Cbar ** 7 + 25.0 ** 7)))
119
+ a1p, a2p = (1 + G) * a1, (1 + G) * a2
120
+ C1p, C2p = np.hypot(a1p, b1), np.hypot(a2p, b2)
121
+ h1p = np.where((a1p == 0) & (b1 == 0), 0.0, np.degrees(np.arctan2(b1, a1p)) % 360)
122
+ h2p = np.where((a2p == 0) & (b2 == 0), 0.0, np.degrees(np.arctan2(b2, a2p)) % 360)
123
+ dLp = L2 - L1
124
+ dCp = C2p - C1p
125
+ dh = h2p - h1p
126
+ dhp = np.where(C1p * C2p == 0, 0.0, np.where(np.abs(dh) <= 180, dh, np.where(dh > 180, dh - 360, dh + 360)))
127
+ dHp = 2 * np.sqrt(C1p * C2p) * np.sin(np.radians(dhp / 2))
128
+ Lbp = (L1 + L2) / 2
129
+ Cbp = (C1p + C2p) / 2
130
+ hsum = h1p + h2p
131
+ hbp = np.where(C1p * C2p == 0, hsum,
132
+ np.where(np.abs(h1p - h2p) <= 180, hsum / 2,
133
+ np.where(hsum < 360, (hsum + 360) / 2, (hsum - 360) / 2)))
134
+ T = (1 - 0.17 * np.cos(np.radians(hbp - 30)) + 0.24 * np.cos(np.radians(2 * hbp))
135
+ + 0.32 * np.cos(np.radians(3 * hbp + 6)) - 0.20 * np.cos(np.radians(4 * hbp - 63)))
136
+ dtheta = 30 * np.exp(-(((hbp - 275) / 25) ** 2))
137
+ RC = 2 * np.sqrt(Cbp ** 7 / (Cbp ** 7 + 25.0 ** 7))
138
+ SL = 1 + 0.015 * (Lbp - 50) ** 2 / np.sqrt(20 + (Lbp - 50) ** 2)
139
+ SC = 1 + 0.045 * Cbp
140
+ SH = 1 + 0.015 * Cbp * T
141
+ RT = -np.sin(np.radians(2 * dtheta)) * RC
142
+ return np.sqrt((dLp / (kL * SL)) ** 2 + (dCp / (kC * SC)) ** 2 + (dHp / (kH * SH)) ** 2
143
+ + RT * (dCp / (kC * SC)) * (dHp / (kH * SH)))
144
+
145
+
146
+ def _luminance(colors) -> np.ndarray:
147
+ lin = _linear(_rgb(colors) if not (isinstance(colors, np.ndarray) and colors.ndim == 2) else colors)
148
+ return lin @ np.array([0.2126, 0.7152, 0.0722])
149
+
150
+
151
+ def contrast(fg, bg) -> float:
152
+ """WCAG 2 contrast ratio between a foreground and a background colour (1 … 21)."""
153
+ lf, lb = float(_luminance(fg)[0]), float(_luminance(bg)[0])
154
+ hi, lo = max(lf, lb), min(lf, lb)
155
+ return (hi + 0.05) / (lo + 0.05)
156
+
157
+
158
+ # ---------------------------------------------------------------------------------------------
159
+ # findings
160
+ # ---------------------------------------------------------------------------------------------
161
+ @dataclass
162
+ class Finding:
163
+ """One accessibility finding: what ``kind`` of problem, between which colours (``a``, ``b``;
164
+ ``b`` is the ground for a contrast finding), the measured ``value`` against the ``threshold``
165
+ it failed, and a sentence a reader can act on."""
166
+
167
+ kind: str
168
+ a: str
169
+ b: Optional[str]
170
+ value: float
171
+ threshold: float
172
+ message: str
173
+
174
+ def as_dict(self) -> dict:
175
+ d = asdict(self)
176
+ d["value"] = round(float(d["value"]), 3)
177
+ return d
178
+
179
+
180
+ def _hex(c) -> str:
181
+ from matplotlib.colors import to_hex
182
+ return to_hex(c)
183
+
184
+
185
+ def check_palette(colors, bg="#ffffff", *, text=None, names=None, thresholds=None) -> List[Finding]:
186
+ """Lint a set of mark colours against one another and their ground.
187
+
188
+ - every pair closer than ΔE 10 under any of the three deficiencies → ``cvd-confusable``;
189
+ - every pair whose lightness differs by less than 10 L* → ``greyscale-confusable``;
190
+ - a mark colour with WCAG contrast < 3:1 against ``bg`` → ``low-contrast-mark``;
191
+ - a ``text`` colour with contrast < 4.5:1 against ``bg`` → ``low-contrast-text``.
192
+ ``names`` label the colours in the messages (default: their hex).
193
+ """
194
+ th = {**THRESHOLDS, **(thresholds or {})}
195
+ colors = list(colors)
196
+ names = list(names) if names is not None else [_hex(c) for c in colors]
197
+ findings: List[Finding] = []
198
+ rgb = _rgb(colors)
199
+ n = len(colors)
200
+ for kind in CVD_KINDS:
201
+ sim = simulate(rgb, kind, 1.0)
202
+ labs = lab(sim)
203
+ for i in range(n):
204
+ for j in range(i + 1, n):
205
+ d = float(_ciede2000(labs[i:i + 1], labs[j:j + 1])[0])
206
+ if d < th["cvd-confusable"]:
207
+ findings.append(Finding("cvd-confusable", _hex(colors[i]), _hex(colors[j]), d, th["cvd-confusable"],
208
+ f"{names[i]} and {names[j]} are near-identical to a {kind[:-3]}ope (ΔE {d:.1f} < {th['cvd-confusable']:g})"))
209
+ L = lab(rgb)[:, 0]
210
+ for i in range(n):
211
+ for j in range(i + 1, n):
212
+ d = float(abs(L[i] - L[j]))
213
+ if d < th["greyscale-confusable"]:
214
+ findings.append(Finding("greyscale-confusable", _hex(colors[i]), _hex(colors[j]), d, th["greyscale-confusable"],
215
+ f"{names[i]} and {names[j]} merge in greyscale (ΔL* {d:.1f} < {th['greyscale-confusable']:g})"))
216
+ for c, name in zip(colors, names):
217
+ ratio = contrast(c, bg)
218
+ if ratio < th["low-contrast-mark"]:
219
+ findings.append(Finding("low-contrast-mark", _hex(c), _hex(bg), ratio, th["low-contrast-mark"],
220
+ f"{name} has {ratio:.2f}:1 contrast against the ground (needs {th['low-contrast-mark']:g}:1)"))
221
+ for c in (text or []):
222
+ ratio = contrast(c, bg)
223
+ if ratio < th["low-contrast-text"]:
224
+ findings.append(Finding("low-contrast-text", _hex(c), _hex(bg), ratio, th["low-contrast-text"],
225
+ f"text in {_hex(c)} has {ratio:.2f}:1 contrast against the ground (needs {th['low-contrast-text']:g}:1)"))
226
+ return findings
227
+
228
+
229
+ def check_colormap(cmap, *, sequential=None, n=256, thresholds=None) -> List[Finding]:
230
+ """Lint a colormap: ``non-uniform`` when the ΔE2000 steps between neighbouring samples vary
231
+ by more than a 0.35 coefficient of variation, ``non-monotone`` when a sequential map's
232
+ lightness does not run one way (``sequential`` defaults to the map's shipped type)."""
233
+ from matplotlib.colors import Colormap
234
+ from .colors import maps
235
+ th = {**THRESHOLDS, **(thresholds or {})}
236
+ cm = cmap if isinstance(cmap, Colormap) else maps._resolve_any(cmap)
237
+ if sequential is None:
238
+ try:
239
+ sequential = maps.info(cm.name).get("type") == "sequential"
240
+ except Exception:
241
+ sequential = False
242
+ rgba = cm(np.linspace(0, 1, n))
243
+ labs = lab(rgba[:, :3])
244
+ steps = _ciede2000(labs[:-1], labs[1:])
245
+ findings: List[Finding] = []
246
+ cv = float(np.std(steps) / np.mean(steps)) if np.mean(steps) > 0 else 0.0
247
+ if cv > th["non-uniform"]:
248
+ findings.append(Finding("non-uniform", cm.name, None, cv, th["non-uniform"],
249
+ f"{cm.name} is not perceptually uniform (ΔE step variation {cv:.2f} > {th['non-uniform']:g})"))
250
+ if sequential:
251
+ dL = np.diff(labs[:, 0])
252
+ tol = 0.5
253
+ if not (np.all(dL >= -tol) or np.all(dL <= tol)):
254
+ reversals = int(np.sum(np.sign(dL[np.abs(dL) > tol])[1:] != np.sign(dL[np.abs(dL) > tol])[:-1]))
255
+ findings.append(Finding("non-monotone", cm.name, None, float(reversals), 0.0,
256
+ f"{cm.name} is sequential but its lightness reverses direction ({reversals}×)"))
257
+ return findings
258
+
259
+
260
+ def check_figure(fig, *, thresholds=None) -> List[Finding]:
261
+ """Lint a figure: its tagged series' primary colours (the B2 colour records) against one another
262
+ and the axes background, and its text inks against the same ground."""
263
+ from matplotlib.colors import to_hex
264
+ from . import tagger
265
+ from .data import primary_paint
266
+ reg = tagger.registry_for(fig)
267
+ colours, names, seen = [], [], set()
268
+ for m in reg.marks:
269
+ if m.series is None or m.role not in ("line", "point", "bar", "area", "box"):
270
+ continue
271
+ paint = primary_paint(m)
272
+ if not paint or paint.get("hex") in (None, "varies") or m.series in seen:
273
+ continue
274
+ seen.add(m.series)
275
+ colours.append(paint["hex"])
276
+ names.append(f"series {m.series!r}")
277
+ if not fig.axes:
278
+ return []
279
+ ax = next((a for a in fig.axes if a.get_label() != "<colorbar>"), fig.axes[0])
280
+ bg = ax.get_facecolor()
281
+ if bg[3] == 0:
282
+ bg = fig.get_facecolor()
283
+ texts = {to_hex(t.get_color()) for t in list(ax.texts) + [ax.title, ax.xaxis.label, ax.yaxis.label] if t.get_text()}
284
+ texts |= {to_hex(t.get_color()) for t in ax.get_xticklabels() + ax.get_yticklabels() if t.get_text()}
285
+ return check_palette(colours, to_hex(bg), text=sorted(texts), names=names, thresholds=thresholds)