bsplot 0.0.2__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 (89) hide show
  1. bsplot/__init__.py +23 -0
  2. bsplot/anat.py +55 -0
  3. bsplot/animate.py +194 -0
  4. bsplot/bioicons.py +777 -0
  5. bsplot/brain.py +188 -0
  6. bsplot/colors.py +1935 -0
  7. bsplot/data/HCP_avg-SC.txt +379 -0
  8. bsplot/data/MNI152.rh.pial +0 -0
  9. bsplot/data/STN_lh.nii.gz +0 -0
  10. bsplot/data/STN_rh.nii.gz +0 -0
  11. bsplot/data/__init__.py +22 -0
  12. bsplot/data/left_electrode.ply +257270 -0
  13. bsplot/data/parcellations/HCP-MMP1.L.label.gii +442 -0
  14. bsplot/data/parcellations/HCP-MMP1.R.label.gii +442 -0
  15. bsplot/data/parcellations/code/split_hcpmmp1.sh +4 -0
  16. bsplot/data/right_electrode.ply +257270 -0
  17. bsplot/data/surface.py +112 -0
  18. bsplot/data/tpl-MNI152NLin2009b_atlas-hcpmmp1_desc-ordered_dseg.nii.gz +0 -0
  19. bsplot/figure.py +177 -0
  20. bsplot/graph/__init__.py +31 -0
  21. bsplot/graph/edges.py +312 -0
  22. bsplot/graph/flowchart.py +762 -0
  23. bsplot/graph/layout.py +201 -0
  24. bsplot/graph/network.py +974 -0
  25. bsplot/graph/nodes.py +452 -0
  26. bsplot/panels.py +110 -0
  27. bsplot/scientific_color_maps/__init__.py +0 -0
  28. bsplot/scientific_color_maps/_sync_from_zenodo_download.sh +1 -0
  29. bsplot/scientific_color_maps/batlowK.txt +256 -0
  30. bsplot/scientific_color_maps/batlowW.txt +256 -0
  31. bsplot/scientific_color_maps/cyclic/bamO.txt +256 -0
  32. bsplot/scientific_color_maps/cyclic/brocO.txt +256 -0
  33. bsplot/scientific_color_maps/cyclic/corkO.txt +256 -0
  34. bsplot/scientific_color_maps/cyclic/romaO.txt +256 -0
  35. bsplot/scientific_color_maps/cyclic/vikO.txt +256 -0
  36. bsplot/scientific_color_maps/diverging/bam.txt +256 -0
  37. bsplot/scientific_color_maps/diverging/berlin.txt +256 -0
  38. bsplot/scientific_color_maps/diverging/broc.txt +256 -0
  39. bsplot/scientific_color_maps/diverging/cork.txt +256 -0
  40. bsplot/scientific_color_maps/diverging/lisbon.txt +256 -0
  41. bsplot/scientific_color_maps/diverging/managua.txt +256 -0
  42. bsplot/scientific_color_maps/diverging/roma.txt +256 -0
  43. bsplot/scientific_color_maps/diverging/tofino.txt +256 -0
  44. bsplot/scientific_color_maps/diverging/vanimo.txt +256 -0
  45. bsplot/scientific_color_maps/diverging/vik.txt +256 -0
  46. bsplot/scientific_color_maps/multisequential/bukavu.txt +256 -0
  47. bsplot/scientific_color_maps/multisequential/fes.txt +256 -0
  48. bsplot/scientific_color_maps/multisequential/oleron.txt +256 -0
  49. bsplot/scientific_color_maps/naviaW.txt +256 -0
  50. bsplot/scientific_color_maps/sequential/acton.txt +256 -0
  51. bsplot/scientific_color_maps/sequential/bamako.txt +256 -0
  52. bsplot/scientific_color_maps/sequential/batlow.txt +256 -0
  53. bsplot/scientific_color_maps/sequential/bilbao.txt +256 -0
  54. bsplot/scientific_color_maps/sequential/buda.txt +256 -0
  55. bsplot/scientific_color_maps/sequential/davos.txt +256 -0
  56. bsplot/scientific_color_maps/sequential/devon.txt +256 -0
  57. bsplot/scientific_color_maps/sequential/glasgow.txt +256 -0
  58. bsplot/scientific_color_maps/sequential/grayC.txt +256 -0
  59. bsplot/scientific_color_maps/sequential/hawaii.txt +256 -0
  60. bsplot/scientific_color_maps/sequential/imola.txt +256 -0
  61. bsplot/scientific_color_maps/sequential/lajolla.txt +256 -0
  62. bsplot/scientific_color_maps/sequential/lapaz.txt +256 -0
  63. bsplot/scientific_color_maps/sequential/lipari.txt +256 -0
  64. bsplot/scientific_color_maps/sequential/navia.txt +256 -0
  65. bsplot/scientific_color_maps/sequential/nuuk.txt +256 -0
  66. bsplot/scientific_color_maps/sequential/oslo.txt +256 -0
  67. bsplot/scientific_color_maps/sequential/tokyo.txt +256 -0
  68. bsplot/scientific_color_maps/sequential/turku.txt +256 -0
  69. bsplot/streamlines.py +314 -0
  70. bsplot/style.py +791 -0
  71. bsplot/styles/black.mplstyle +78 -0
  72. bsplot/styles/bss.mplstyle +50 -0
  73. bsplot/styles/bwcomp.mplstyle +30 -0
  74. bsplot/styles/nature.mplstyle +57 -0
  75. bsplot/styles/transparent.mplstyle +16 -0
  76. bsplot/styles/tvbo.mplstyle +57 -0
  77. bsplot/surface.py +2370 -0
  78. bsplot/templates.py +270 -0
  79. bsplot/text.py +199 -0
  80. bsplot/text2obj.py +59 -0
  81. bsplot/timeseries.py +10 -0
  82. bsplot/utils.py +13 -0
  83. bsplot/volume.py +958 -0
  84. bsplot-0.0.2.dist-info/METADATA +60 -0
  85. bsplot-0.0.2.dist-info/RECORD +89 -0
  86. bsplot-0.0.2.dist-info/WHEEL +5 -0
  87. bsplot-0.0.2.dist-info/entry_points.txt +2 -0
  88. bsplot-0.0.2.dist-info/licenses/LICENSE +193 -0
  89. bsplot-0.0.2.dist-info/top_level.txt +1 -0
bsplot/colors.py ADDED
@@ -0,0 +1,1935 @@
1
+ import json
2
+ import os
3
+ import re
4
+
5
+ import matplotlib as mpl
6
+ import matplotlib.colors as mcolors
7
+ import matplotlib.pyplot as plt
8
+ import pandas as pd
9
+ import yaml
10
+ from matplotlib.colors import rgb2hex
11
+
12
+ # from pybtex.database import Entry
13
+ import numpy as np
14
+ import matplotlib.cm as cm
15
+ from cycler import cycler
16
+
17
+ try:
18
+ import cmap as cmap_lib
19
+ HAS_CMAP = True
20
+ except ImportError:
21
+ HAS_CMAP = False
22
+
23
+
24
+ class Palette:
25
+ """
26
+ A color palette class for managing discrete sets of colors.
27
+
28
+ Use `Palette.list_available()` to see built-in palettes and
29
+ `Palette.load(name)` to load them.
30
+ """
31
+
32
+ # Registry of built-in palettes
33
+ _registry = {}
34
+
35
+ def __init__(self, colors=None, reference=None, **kwargs):
36
+ """
37
+ Initialize the palette with an optional dictionary of colors.
38
+
39
+ Parameters:
40
+ colors (dict): Optional dictionary of colors where each key is a color name,
41
+ and each value is either a HEX string or an RGB tuple.
42
+ """
43
+ self.colors = {}
44
+
45
+ if "name" in kwargs:
46
+ self.name = kwargs.pop("name")
47
+ else:
48
+ self.name = "Custom Palette"
49
+
50
+ if colors is not None:
51
+
52
+ if isinstance(colors, dict):
53
+ for name, color in colors.items():
54
+ if isinstance(color, tuple) and len(color) == 3:
55
+ self.add_color(name, rgb=color)
56
+ elif isinstance(color, str) and color.startswith("#"):
57
+ self.add_color(name, hex_code=color)
58
+ else:
59
+ raise ValueError(
60
+ "Colors must be in RGB tuple or HEX string format."
61
+ )
62
+ else:
63
+ for color in colors:
64
+ if len(color) == 3:
65
+ self.add_color(f"color_{len(self.colors) + 1}", rgb=color)
66
+ elif isinstance(color, str) and color.startswith("#"):
67
+ self.add_color(f"color_{len(self.colors) + 1}", hex_code=color)
68
+ else:
69
+ raise ValueError(
70
+ "Colors must be in RGB tuple or HEX string format."
71
+ )
72
+
73
+ if reference:
74
+ self.reference = reference
75
+
76
+ def __repr__(self):
77
+ return f"Palette('{self.name}', {len(self.colors)} colors)"
78
+
79
+ @classmethod
80
+ def register(cls, name, palette):
81
+ """Register a palette in the registry."""
82
+ cls._registry[name] = palette
83
+ palette.name = name
84
+ return palette
85
+
86
+ @classmethod
87
+ def list_available(cls):
88
+ """
89
+ List all available built-in palettes.
90
+
91
+ Returns:
92
+ list: List of palette names.
93
+ """
94
+ return list(cls._registry.keys())
95
+
96
+ @classmethod
97
+ def load(cls, name):
98
+ """
99
+ Load a palette by name.
100
+
101
+ Parameters:
102
+ name (str): Name of the palette.
103
+
104
+ Returns:
105
+ Palette: The requested palette.
106
+
107
+ Raises:
108
+ ValueError: If palette not found.
109
+ """
110
+ if name not in cls._registry:
111
+ available = cls.list_available()
112
+ raise ValueError(
113
+ f"Palette '{name}' not found. Available: {', '.join(available)}"
114
+ )
115
+ return cls._registry[name]
116
+
117
+ @classmethod
118
+ def from_cmap(cls, cmap, n=8, start=0.0, stop=1.0, name=None):
119
+ """
120
+ Create a Palette by sampling colors from a colormap.
121
+
122
+ Parameters:
123
+ cmap: Colormap name (str) or colormap instance.
124
+ n (int): Number of colors to sample.
125
+ start (float): Start of sampling range [0, 1].
126
+ stop (float): End of sampling range [0, 1].
127
+ name (str): Optional name for the palette.
128
+
129
+ Returns:
130
+ Palette: A new Palette with sampled colors.
131
+
132
+ Example:
133
+ >>> palette = Palette.from_cmap('viridis', n=5)
134
+ >>> palette = Palette.from_cmap('batlow', n=8) # scientific cmap
135
+ """
136
+ # Get the colormap (Colormap handles str -> cmap conversion)
137
+ if isinstance(cmap, str):
138
+ cmap_obj = Colormap(cmap)
139
+ cmap_name = cmap
140
+ else:
141
+ cmap_obj = cmap
142
+ cmap_name = getattr(cmap, "name", "custom")
143
+
144
+ # Sample colors
145
+ t = np.linspace(start, stop, n)
146
+ sampled_colors = {}
147
+ for i, val in enumerate(t):
148
+ rgba = cmap_obj(val)
149
+ hex_color = mcolors.to_hex(rgba)
150
+ sampled_colors[f"color_{i + 1}"] = hex_color
151
+
152
+ palette_name = name or f"{cmap_name}_{n}"
153
+ return cls(sampled_colors, name=palette_name)
154
+
155
+ def add_color(self, name, rgb=None, hex_code=None):
156
+ """
157
+ Add a color to the palette.
158
+
159
+ Parameters:
160
+ name (str): Name of the color.
161
+ rgb (tuple): RGB tuple (R, G, B) with values from 0 to 255.
162
+ hex_code (str): HEX color code as a string (e.g., '#FF5733').
163
+ """
164
+ if rgb is not None:
165
+ if max(rgb) > 1:
166
+ rgb = tuple(value / 255 for value in rgb)
167
+ hex_color = rgb2hex(rgb)
168
+ elif hex_code is not None:
169
+ hex_color = hex_code
170
+ rgb = tuple(int(hex_code[i : i + 2], 16) for i in (1, 3, 5))
171
+ else:
172
+ raise ValueError("Either rgb or hex_code must be provided.")
173
+
174
+ self.colors[name] = {"rgb": rgb, "hex": hex_color}
175
+
176
+ def get_color(self, name):
177
+ """
178
+ Retrieve a color from the palette by name.
179
+
180
+ Parameters:
181
+ name (str): Name of the color.
182
+
183
+ Returns:
184
+ dict: A dictionary with RGB and HEX values of the color.
185
+ """
186
+ return self.colors.get(name, None)
187
+
188
+ def get_hex_colors(self):
189
+ """
190
+ Get a list of all colors in HEX format.
191
+
192
+ Returns:
193
+ list: List of HEX color codes as strings.
194
+ """
195
+ return [color["hex"] for color in self.colors.values()]
196
+
197
+ def get_rgb_colors(self):
198
+ """
199
+ Get a list of all colors in RGB format.
200
+
201
+ Returns:
202
+ list: List of RGB color tuples.
203
+ """
204
+ return [color["rgb"] for color in self.colors.values()]
205
+
206
+ def to_json(self, file_path=None):
207
+ """
208
+ Export the palette as JSON.
209
+
210
+ Parameters:
211
+ file_path (str): Optional path to save the JSON file.
212
+
213
+ Returns:
214
+ str: JSON string if file_path is None, otherwise saves JSON to file.
215
+ """
216
+ if file_path:
217
+ with open(file_path, "w") as f:
218
+ json.dump(self.colors, f, indent=4)
219
+ return json.dumps(self.colors, indent=4)
220
+
221
+ def to_yaml(self, file_path=None):
222
+ """
223
+ Export the palette as YAML.
224
+
225
+ Parameters:
226
+ file_path (str): Optional path to save the YAML file.
227
+
228
+ Returns:
229
+ str: YAML string if file_path is None, otherwise saves YAML to file.
230
+ """
231
+ if file_path:
232
+ with open(file_path, "w") as f:
233
+ yaml.dump(self.colors, f)
234
+ return yaml.dump(self.colors)
235
+
236
+ def to_css(self, file_path=None):
237
+ """
238
+ Export the palette as a CSS custom properties file.
239
+
240
+ Parameters:
241
+ file_path (str): Optional path to save the CSS file.
242
+
243
+ Returns:
244
+ str: CSS string if file_path is None, otherwise saves CSS to file.
245
+ """
246
+ css = ":root {\n"
247
+ for name, color in self.colors.items():
248
+ css += f" --{name.lower().replace(' ', '-')}: {color['hex']};\n"
249
+ css += "}"
250
+
251
+ if file_path:
252
+ with open(file_path, "w") as f:
253
+ f.write(css)
254
+ return css
255
+
256
+ def plot(self):
257
+ """
258
+ Display a bar plot of the colors in the palette.
259
+ """
260
+ hex_colors = self.get_hex_colors()
261
+ color_names = list(self.colors.keys())
262
+
263
+ fig, ax = plt.subplots(figsize=(len(hex_colors), 1))
264
+ ax.imshow([hex_colors], aspect="auto", extent=[0, len(hex_colors), 0, 1])
265
+ ax.set_yticks([])
266
+
267
+ # Add color labels below each color bar
268
+ ax.set_xticks(range(len(hex_colors)))
269
+ ax.set_xticklabels(color_names, rotation=45, ha="right")
270
+
271
+ plt.tight_layout()
272
+ plt.show()
273
+
274
+ def update_rc_params(self):
275
+ """
276
+ Update matplotlib's rcParams using the palette colors.
277
+ Sets the color cycle and other relevant properties.
278
+ """
279
+ hex_colors = self.get_hex_colors()
280
+
281
+ # Update color cycle and other rcParams with available colors
282
+ mpl.rcParams["axes.prop_cycle"] = mpl.cycler("color", hex_colors)
283
+
284
+ def create_sequential_colormaps(self, base_color="#FFFFFF"):
285
+ """
286
+ Create sequential colormaps from white to each color in the palette.
287
+
288
+ Returns:
289
+ dict: A dictionary where keys are color names and values are
290
+ LinearSegmentedColormap instances transitioning from white
291
+ to the respective color.
292
+ """
293
+ colormaps = {}
294
+
295
+ for name, color in self.colors.items():
296
+ # Create a colormap that transitions from white to the color
297
+ cmap = mcolors.LinearSegmentedColormap.from_list(
298
+ f"{name}_sequential", [base_color, color["hex"]]
299
+ )
300
+ colormaps[name] = cmap
301
+
302
+ return colormaps
303
+
304
+ def create_colormap(self, type="linear"):
305
+ """
306
+ Create a colormap from the palette colors.
307
+
308
+ Returns:
309
+ LinearSegmentedColormap: A colormap transitioning between the colors in the palette.
310
+ """
311
+
312
+ # Create a colormap that transitions between the colors in the palette
313
+ colors = [color["hex"] for color in self.colors.values()]
314
+ if type == "linear":
315
+ cmap = mcolors.LinearSegmentedColormap.from_list("custom", colors)
316
+ if type == "listed":
317
+ cmap = mcolors.ListedColormap(colors)
318
+ cmap.name = self.name
319
+ return cmap
320
+
321
+ def get_cmap(self, type="linear"):
322
+ return self.create_colormap(type=type)
323
+
324
+ @property
325
+ def hex(self):
326
+ """Get HEX colors as a property."""
327
+ return self.get_hex_colors()
328
+
329
+ @property
330
+ def rgb(self):
331
+ """Get RGB colors as a property."""
332
+ return self.get_rgb_colors()
333
+
334
+
335
+ # Register built-in palettes
336
+
337
+ # Wong (2011) colorblind-safe palette - Nature Methods
338
+ Palette.register(
339
+ "colorblind",
340
+ Palette(
341
+ {
342
+ "Black": (0, 0, 0),
343
+ "Orange": (230, 159, 0),
344
+ "Sky Blue": (86, 180, 233),
345
+ "Bluish Green": (0, 158, 115),
346
+ "Yellow": (240, 228, 66),
347
+ "Blue": (0, 114, 178),
348
+ "Vermillion": (213, 94, 0),
349
+ "Reddish Purple": (204, 121, 167),
350
+ },
351
+ ),
352
+ )
353
+
354
+ # NPG (Nature Publishing Group) from ggsci
355
+ Palette.register(
356
+ "npg",
357
+ Palette(
358
+ {
359
+ "Red": "#E64B35",
360
+ "Blue": "#4DBBD5",
361
+ "Green": "#00A087",
362
+ "Dark Blue": "#3C5488",
363
+ "Peach": "#F39B7F",
364
+ "Lavender": "#8491B4",
365
+ "Teal": "#91D1C2",
366
+ "Crimson": "#DC0000",
367
+ "Brown": "#7E6148",
368
+ },
369
+ ),
370
+ )
371
+
372
+ # Lancet from ggsci
373
+ Palette.register(
374
+ "lancet",
375
+ Palette(
376
+ {
377
+ "Dark Blue": "#00468B",
378
+ "Light Blue": "#42B540",
379
+ "Orange": "#ED1C24",
380
+ "Cyan": "#0099B4",
381
+ "Brown": "#925E9F",
382
+ "Red": "#FDAF91",
383
+ "Gray": "#AD002A",
384
+ "Teal": "#ADB6B6",
385
+ "Black": "#1B1919",
386
+ },
387
+ ),
388
+ )
389
+
390
+ # JAMA from ggsci
391
+ Palette.register(
392
+ "jama",
393
+ Palette(
394
+ {
395
+ "Dark Blue": "#374E55",
396
+ "Red": "#DF8F44",
397
+ "Teal": "#00A1D5",
398
+ "Brown": "#B24745",
399
+ "Gray": "#79AF97",
400
+ "Light": "#6A6599",
401
+ "Coral": "#80796B",
402
+ },
403
+ ),
404
+ )
405
+
406
+ # Okabe-Ito colorblind-safe (alternative name)
407
+ Palette.register(
408
+ "okabe_ito",
409
+ Palette(
410
+ {
411
+ "Orange": "#E69F00",
412
+ "Sky Blue": "#56B4E9",
413
+ "Bluish Green": "#009E73",
414
+ "Yellow": "#F0E442",
415
+ "Blue": "#0072B2",
416
+ "Vermillion": "#D55E00",
417
+ "Reddish Purple": "#CC79A7",
418
+ "Black": "#000000",
419
+ },
420
+ ),
421
+ )
422
+
423
+ # Paul Tol's qualitative palette (muted)
424
+ Palette.register(
425
+ "tol_muted",
426
+ Palette(
427
+ {
428
+ "Indigo": "#332288",
429
+ "Cyan": "#88CCEE",
430
+ "Teal": "#44AA99",
431
+ "Green": "#117733",
432
+ "Olive": "#999933",
433
+ "Sand": "#DDCC77",
434
+ "Rose": "#CC6677",
435
+ "Wine": "#882255",
436
+ "Purple": "#AA4499",
437
+ },
438
+ ),
439
+ )
440
+
441
+ # Paul Tol's bright palette
442
+ Palette.register(
443
+ "tol_bright",
444
+ Palette(
445
+ {
446
+ "Blue": "#4477AA",
447
+ "Cyan": "#66CCEE",
448
+ "Green": "#228833",
449
+ "Yellow": "#CCBB44",
450
+ "Red": "#EE6677",
451
+ "Purple": "#AA3377",
452
+ "Grey": "#BBBBBB",
453
+ },
454
+ ),
455
+ )
456
+
457
+ # Paul Tol's vibrant palette
458
+ Palette.register(
459
+ "tol_vibrant",
460
+ Palette(
461
+ {
462
+ "Orange": "#EE7733",
463
+ "Blue": "#0077BB",
464
+ "Cyan": "#33BBEE",
465
+ "Magenta": "#EE3377",
466
+ "Red": "#CC3311",
467
+ "Teal": "#009988",
468
+ "Grey": "#BBBBBB",
469
+ },
470
+ ),
471
+ )
472
+
473
+ # Tableau 10
474
+ Palette.register(
475
+ "tableau10",
476
+ Palette(
477
+ {
478
+ "Blue": "#4E79A7",
479
+ "Orange": "#F28E2B",
480
+ "Red": "#E15759",
481
+ "Teal": "#76B7B2",
482
+ "Green": "#59A14F",
483
+ "Yellow": "#EDC948",
484
+ "Purple": "#B07AA1",
485
+ "Pink": "#FF9DA7",
486
+ "Brown": "#9C755F",
487
+ "Gray": "#BAB0AC",
488
+ },
489
+ ),
490
+ )
491
+
492
+ # Seaborn deep
493
+ Palette.register(
494
+ "seaborn_deep",
495
+ Palette(
496
+ {
497
+ "Blue": "#4C72B0",
498
+ "Orange": "#DD8452",
499
+ "Green": "#55A868",
500
+ "Red": "#C44E52",
501
+ "Purple": "#8172B3",
502
+ "Brown": "#937860",
503
+ "Pink": "#DA8BC3",
504
+ "Gray": "#8C8C8C",
505
+ "Yellow": "#CCB974",
506
+ "Cyan": "#64B5CD",
507
+ },
508
+ ),
509
+ )
510
+
511
+ # Seaborn colorblind
512
+ Palette.register(
513
+ "seaborn_colorblind",
514
+ Palette(
515
+ {
516
+ "Blue": "#0173B2",
517
+ "Orange": "#DE8F05",
518
+ "Green": "#029E73",
519
+ "Red": "#D55E00",
520
+ "Purple": "#CC78BC",
521
+ "Brown": "#CA9161",
522
+ "Pink": "#FBAFE4",
523
+ "Gray": "#949494",
524
+ "Yellow": "#ECE133",
525
+ "Cyan": "#56B4E9",
526
+ },
527
+ ),
528
+ )
529
+
530
+ # Set1 from ColorBrewer
531
+ Palette.register(
532
+ "set1",
533
+ Palette(
534
+ {
535
+ "Red": "#E41A1C",
536
+ "Blue": "#377EB8",
537
+ "Green": "#4DAF4A",
538
+ "Purple": "#984EA3",
539
+ "Orange": "#FF7F00",
540
+ "Yellow": "#FFFF33",
541
+ "Brown": "#A65628",
542
+ "Pink": "#F781BF",
543
+ "Gray": "#999999",
544
+ },
545
+ ),
546
+ )
547
+
548
+ # Set2 from ColorBrewer
549
+ Palette.register(
550
+ "set2",
551
+ Palette(
552
+ {
553
+ "Teal": "#66C2A5",
554
+ "Orange": "#FC8D62",
555
+ "Purple": "#8DA0CB",
556
+ "Pink": "#E78AC3",
557
+ "Green": "#A6D854",
558
+ "Yellow": "#FFD92F",
559
+ "Brown": "#E5C494",
560
+ "Gray": "#B3B3B3",
561
+ },
562
+ ),
563
+ )
564
+
565
+ # Dark2 from ColorBrewer
566
+ Palette.register(
567
+ "dark2",
568
+ Palette(
569
+ {
570
+ "Teal": "#1B9E77",
571
+ "Orange": "#D95F02",
572
+ "Purple": "#7570B3",
573
+ "Pink": "#E7298A",
574
+ "Green": "#66A61E",
575
+ "Yellow": "#E6AB02",
576
+ "Brown": "#A6761D",
577
+ "Gray": "#666666",
578
+ },
579
+ ),
580
+ )
581
+
582
+ # Paired from ColorBrewer (good for paired comparisons)
583
+ Palette.register(
584
+ "paired",
585
+ Palette(
586
+ {
587
+ "Light Blue": "#A6CEE3",
588
+ "Blue": "#1F78B4",
589
+ "Light Green": "#B2DF8A",
590
+ "Green": "#33A02C",
591
+ "Light Pink": "#FB9A99",
592
+ "Red": "#E31A1C",
593
+ "Light Orange": "#FDBF6F",
594
+ "Orange": "#FF7F00",
595
+ "Light Purple": "#CAB2D6",
596
+ "Purple": "#6A3D9A",
597
+ },
598
+ ),
599
+ )
600
+
601
+ # Register aliases for backward compatibility
602
+ Palette._registry["ggsci"] = Palette._registry["npg"] # ggsci is an alias for npg
603
+
604
+ # Backward compatibility aliases
605
+ colorblind_palette = Palette.load("colorblind")
606
+ ggsci_palette = Palette.load("ggsci")
607
+
608
+
609
+ class _ColormapMixin:
610
+ """
611
+ Mixin providing additional methods for colormaps.
612
+
613
+ This mixin adds intuitive methods for sampling, reversing, and converting
614
+ colormaps that work with both continuous and discrete colormaps.
615
+ """
616
+
617
+ def sample(self, n=5, start=0.0, stop=1.0, as_hex=False):
618
+ """
619
+ Sample n colors from this colormap.
620
+
621
+ Parameters:
622
+ n (int): Number of colors to sample.
623
+ start (float): Start of sampling range [0, 1].
624
+ stop (float): End of sampling range [0, 1].
625
+ as_hex (bool): If True, return hex strings instead of RGBA tuples.
626
+
627
+ Returns:
628
+ list: List of colors (RGBA tuples or hex strings).
629
+
630
+ Example:
631
+ >>> cmap = Colormap("viridis")
632
+ >>> cmap.sample(5, as_hex=True)
633
+ ['#440154', '#3b528b', '#21918c', '#5ec962', '#fde725']
634
+ """
635
+ t = np.linspace(start, stop, n)
636
+ colors = [self(v) for v in t]
637
+ if as_hex:
638
+ colors = [mcolors.to_hex(c) for c in colors]
639
+ return colors
640
+
641
+ def to_palette(self, n=8, start=0.0, stop=1.0, name=None):
642
+ """
643
+ Convert this colormap to a Palette by sampling colors.
644
+
645
+ Parameters:
646
+ n (int): Number of colors to sample.
647
+ start (float): Start of sampling range [0, 1].
648
+ stop (float): End of sampling range [0, 1].
649
+ name (str): Optional name for the palette.
650
+
651
+ Returns:
652
+ Palette: A new Palette instance with sampled colors.
653
+
654
+ Example:
655
+ >>> cmap = Colormap("batlow")
656
+ >>> palette = cmap.to_palette(n=6)
657
+ """
658
+ hex_colors = self.sample(n=n, start=start, stop=stop, as_hex=True)
659
+ palette_name = name or f"{self.name}_{n}"
660
+ return Palette(hex_colors, name=palette_name)
661
+
662
+ def truncate(self, start=0.0, stop=1.0, N=256, name=None):
663
+ """
664
+ Create a new colormap from a subset of this colormap's range.
665
+
666
+ Parameters:
667
+ start (float): Start of the range [0, 1].
668
+ stop (float): End of the range [0, 1].
669
+ N (int): Number of quantization levels.
670
+ name (str): Optional name for the new colormap.
671
+
672
+ Returns:
673
+ ContinuousColormap: A new colormap covering the specified range.
674
+
675
+ Example:
676
+ >>> cmap = Colormap("viridis")
677
+ >>> truncated = cmap.truncate(0.2, 0.8)
678
+ """
679
+ colors = self.sample(n=N, start=start, stop=stop)
680
+ cmap_name = name or f"{self.name}_truncated"
681
+ return ContinuousColormap.from_list(cmap_name, colors, N=N)
682
+
683
+ # ------------------------------------------------------------------
684
+ # Color-vision analysis helpers
685
+ # ------------------------------------------------------------------
686
+
687
+ # Vienot (1999) / Brettel (1997) colorblind simulation matrices.
688
+ # Applied in linear RGB space (after sRGB gamma removal).
689
+ _CVD_MATRICES = {
690
+ "protanopia": np.array([
691
+ [0.152286, 1.052583, -0.204868],
692
+ [0.114503, 0.786281, 0.099216],
693
+ [-0.003882, -0.048116, 1.051998],
694
+ ]),
695
+ "deuteranopia": np.array([
696
+ [0.367322, 0.860646, -0.227968],
697
+ [0.280085, 0.672501, 0.047414],
698
+ [-0.011820, 0.042940, 0.968881],
699
+ ]),
700
+ "tritanopia": np.array([
701
+ [1.255528, -0.076749, -0.178779],
702
+ [-0.078411, 0.930809, 0.147602],
703
+ [0.004733, 0.691367, 0.303900],
704
+ ]),
705
+ }
706
+
707
+ @staticmethod
708
+ def _srgb_to_linear(c):
709
+ """Convert sRGB [0-1] to linear RGB."""
710
+ c = np.asarray(c, dtype=float)
711
+ return np.where(c <= 0.04045, c / 12.92,
712
+ ((c + 0.055) / 1.055) ** 2.4)
713
+
714
+ @staticmethod
715
+ def _linear_to_srgb(c):
716
+ """Convert linear RGB to sRGB [0-1]."""
717
+ c = np.asarray(c, dtype=float)
718
+ return np.where(c <= 0.0031308, 12.92 * c,
719
+ 1.055 * np.power(np.clip(c, 0, None), 1.0 / 2.4) - 0.055)
720
+
721
+ def _sample_rgba(self, N=256):
722
+ """Sample the colormap into an (N, 4) RGBA array."""
723
+ t = np.linspace(0, 1, N)
724
+ return np.array([self(v) for v in t])
725
+
726
+ def to_grayscale(self, N=256, name=None):
727
+ """
728
+ Create a grayscale version of this colormap using perceptual luminance.
729
+
730
+ Uses the Rec. 709 luminance weights (0.2126 R + 0.7152 G + 0.0722 B)
731
+ applied in linear RGB space for accurate perceptual conversion.
732
+
733
+ Parameters:
734
+ N (int): Number of quantization levels.
735
+ name (str): Name for the new colormap.
736
+
737
+ Returns:
738
+ ContinuousColormap: A grayscale version of this colormap.
739
+
740
+ Example:
741
+ >>> cmap = Colormap("viridis")
742
+ >>> gray = cmap.to_grayscale()
743
+ """
744
+ rgba = self._sample_rgba(N)
745
+ rgb_linear = self._srgb_to_linear(rgba[:, :3])
746
+ lum = 0.2126 * rgb_linear[:, 0] + 0.7152 * rgb_linear[:, 1] + 0.0722 * rgb_linear[:, 2]
747
+ lum_srgb = self._linear_to_srgb(lum)
748
+ gray_colors = np.column_stack([lum_srgb, lum_srgb, lum_srgb,
749
+ rgba[:, 3]])
750
+ cmap_name = name or f"{self.name}_grayscale"
751
+ return ContinuousColormap.from_list(cmap_name, gray_colors.tolist(), N=N)
752
+
753
+ def colorblind(self, cvd_type="deuteranopia", N=256, name=None):
754
+ """
755
+ Simulate how this colormap appears under color vision deficiency.
756
+
757
+ Parameters:
758
+ cvd_type (str): Type of color vision deficiency. One of
759
+ 'protanopia', 'deuteranopia', or 'tritanopia'.
760
+ N (int): Number of quantization levels.
761
+ name (str): Name for the new colormap.
762
+
763
+ Returns:
764
+ ContinuousColormap: A colormap simulating the specified CVD.
765
+
766
+ Example:
767
+ >>> cmap = Colormap("viridis")
768
+ >>> deut = cmap.colorblind("deuteranopia")
769
+ """
770
+ cvd_type = cvd_type.lower()
771
+ if cvd_type not in self._CVD_MATRICES:
772
+ raise ValueError(
773
+ f"Unknown CVD type '{cvd_type}'. "
774
+ f"Choose from: {', '.join(self._CVD_MATRICES.keys())}"
775
+ )
776
+
777
+ mat = self._CVD_MATRICES[cvd_type]
778
+ rgba = self._sample_rgba(N)
779
+
780
+ # Convert sRGB → linear → apply CVD matrix → linear → sRGB
781
+ rgb_linear = self._srgb_to_linear(rgba[:, :3])
782
+ rgb_cvd = rgb_linear @ mat.T
783
+ rgb_cvd = np.clip(rgb_cvd, 0, 1)
784
+ rgb_srgb = self._linear_to_srgb(rgb_cvd)
785
+ rgb_srgb = np.clip(rgb_srgb, 0, 1)
786
+
787
+ cvd_colors = np.column_stack([rgb_srgb, rgba[:, 3]])
788
+ cmap_name = name or f"{self.name}_{cvd_type}"
789
+ return ContinuousColormap.from_list(cmap_name, cvd_colors.tolist(), N=N)
790
+
791
+ def lightness(self, N=256):
792
+ """
793
+ Compute the CIELAB L* lightness profile across the colormap.
794
+
795
+ Parameters:
796
+ N (int): Number of sample points.
797
+
798
+ Returns:
799
+ numpy.ndarray: Array of L* values (0–100) of shape (N,).
800
+
801
+ Example:
802
+ >>> cmap = Colormap("viridis")
803
+ >>> L = cmap.lightness()
804
+ """
805
+ rgba = self._sample_rgba(N)
806
+ rgb_linear = self._srgb_to_linear(rgba[:, :3])
807
+
808
+ # sRGB D65 whitepoint → XYZ conversion (Rec. 709 primaries)
809
+ M_rgb_to_xyz = np.array([
810
+ [0.4124564, 0.3575761, 0.1804375],
811
+ [0.2126729, 0.7151522, 0.0721750],
812
+ [0.0193339, 0.1191920, 0.9503041],
813
+ ])
814
+ xyz = rgb_linear @ M_rgb_to_xyz.T
815
+
816
+ # D65 white point
817
+ Yn = 1.0
818
+ y_ratio = xyz[:, 1] / Yn
819
+
820
+ # CIE L* formula
821
+ delta = 6.0 / 29.0
822
+ f_y = np.where(y_ratio > delta ** 3,
823
+ np.cbrt(y_ratio),
824
+ y_ratio / (3.0 * delta ** 2) + 4.0 / 29.0)
825
+ L_star = 116.0 * f_y - 16.0
826
+ return L_star
827
+
828
+ def plot_analysis(self, N=256, figsize=None):
829
+ """
830
+ Plot a comprehensive colormap analysis panel.
831
+
832
+ Shows the original colormap, its grayscale conversion, lightness
833
+ profile, and three color-vision deficiency simulations (protanopia,
834
+ deuteranopia, tritanopia).
835
+
836
+ Parameters:
837
+ N (int): Number of sample points.
838
+ figsize (tuple): Figure size (width, height). Default auto.
839
+
840
+ Returns:
841
+ matplotlib.figure.Figure: The analysis figure.
842
+
843
+ Example:
844
+ >>> cmap = Colormap("viridis")
845
+ >>> fig = cmap.plot_analysis()
846
+ """
847
+ gradient = np.linspace(0, 1, N).reshape(1, -1)
848
+
849
+ rows = [
850
+ ("Original", self),
851
+ ("Grayscale", self.to_grayscale(N)),
852
+ ("Protanopia", self.colorblind("protanopia", N)),
853
+ ("Deuteranopia", self.colorblind("deuteranopia", N)),
854
+ ("Tritanopia", self.colorblind("tritanopia", N)),
855
+ ]
856
+
857
+ fig_w = figsize[0] if figsize else 10
858
+ fig_h = figsize[1] if figsize else (len(rows) * 0.6 + 2)
859
+
860
+ fig, axes = plt.subplots(len(rows) + 1, 1,
861
+ figsize=(fig_w, fig_h),
862
+ gridspec_kw={"height_ratios":
863
+ [1] * len(rows) + [2]})
864
+
865
+ for ax, (label, cm) in zip(axes[:-1], rows):
866
+ ax.imshow(gradient, aspect="auto", cmap=cm)
867
+ ax.set_yticks([])
868
+ ax.set_xticks([])
869
+ ax.set_ylabel(label, rotation=0, ha="right", va="center",
870
+ fontsize=9)
871
+
872
+ # Lightness plot
873
+ ax_l = axes[-1]
874
+ t = np.linspace(0, 1, N)
875
+ L = self.lightness(N)
876
+ L_gray = self.to_grayscale(N).lightness(N)
877
+
878
+ # Color the lightness line using the colormap
879
+ for i in range(N - 1):
880
+ ax_l.plot(t[i:i + 2], L[i:i + 2], color=self(t[i]), lw=2)
881
+ ax_l.plot(t, L_gray, color="0.5", lw=1, ls="--", label="grayscale")
882
+ ax_l.set_ylabel("L*", fontsize=9)
883
+ ax_l.set_xlabel("Colormap position", fontsize=9)
884
+ ax_l.set_xlim(0, 1)
885
+ ax_l.set_ylim(0, 100)
886
+ ax_l.legend(fontsize=8, loc="best")
887
+
888
+ fig.suptitle(f"Colormap analysis: {self.name}", fontsize=11)
889
+ fig.tight_layout()
890
+ return fig
891
+
892
+
893
+ class ContinuousColormap(_ColormapMixin, mcolors.LinearSegmentedColormap):
894
+ """
895
+ Extended LinearSegmentedColormap with additional convenience methods.
896
+
897
+ Inherits all functionality from matplotlib's LinearSegmentedColormap
898
+ and adds methods like sample(), to_palette(), and truncate().
899
+ """
900
+
901
+ @classmethod
902
+ def from_list(cls, name, colors, N=256, gamma=1.0):
903
+ """Create a ContinuousColormap from a list of colors."""
904
+ # Use parent's from_list but ensure we get our subclass back
905
+ parent_cmap = mcolors.LinearSegmentedColormap.from_list(name, colors, N, gamma)
906
+ # Create our subclass instance with the same data
907
+ cmap = cls(name, parent_cmap._segmentdata, N, gamma)
908
+ return cmap
909
+
910
+
911
+ class DiscreteColormap(_ColormapMixin, mcolors.ListedColormap):
912
+ """
913
+ Extended ListedColormap with additional convenience methods.
914
+
915
+ Inherits all functionality from matplotlib's ListedColormap
916
+ and adds methods like sample(), to_palette(), and truncate().
917
+ """
918
+ pass
919
+
920
+
921
+ class Colormap:
922
+ """
923
+ Factory for creating extended colormaps with additional methods.
924
+
925
+ Creates ContinuousColormap or DiscreteColormap instances that extend
926
+ matplotlib's colormaps with convenient methods like sample(), to_palette(),
927
+ and truncate().
928
+
929
+ Supports loading from:
930
+ - Matplotlib colormap names ("viridis", "plasma", etc.)
931
+ - Scientific colormap names ("batlow", "vik", etc.)
932
+ - cmap package colormaps ("cmasher:ember", "cmocean:deep", etc.)
933
+ - Lists of colors (hex strings, RGB tuples, or named colors)
934
+ - Palette instances
935
+
936
+ Usage:
937
+ cmap = Colormap("viridis") # Load matplotlib cmap
938
+ cmap = Colormap("batlow") # Load scientific cmap
939
+ cmap = Colormap("cmasher:ember") # Load from cmap package
940
+ cmap = Colormap(["#ff0000", "#00ff00", "#0000ff"]) # From colors
941
+ cmap = Colormap(my_palette) # From Palette instance
942
+
943
+ # Extended methods
944
+ colors = cmap.sample(5, as_hex=True)
945
+ palette = cmap.to_palette(n=8)
946
+ truncated = cmap.truncate(0.2, 0.8)
947
+ """
948
+
949
+ CMAP_BASE_PATH = os.path.join(os.path.dirname(__file__), "scientific_color_maps")
950
+ CMAP_TYPES = ("sequential", "diverging", "multisequential", "cyclic", "qualitative")
951
+
952
+ def __new__(cls, source, name=None, N=256, discrete=False):
953
+ """
954
+ Create an extended colormap from various sources.
955
+
956
+ Parameters:
957
+ source: Colormap name (str), list of colors, Palette, or existing colormap.
958
+ name (str): Optional name override.
959
+ N (int): Number of RGB quantization levels (for continuous colormaps).
960
+ discrete (bool): If True, create a DiscreteColormap instead of continuous.
961
+
962
+ Returns:
963
+ ContinuousColormap or DiscreteColormap: An extended matplotlib colormap.
964
+ """
965
+ # Already one of our extended colormaps - return as-is
966
+ if isinstance(source, (ContinuousColormap, DiscreteColormap)):
967
+ return source
968
+
969
+ # Wrap existing matplotlib colormap
970
+ if isinstance(source, mcolors.Colormap):
971
+ return cls._wrap_mpl_colormap(source, N)
972
+
973
+ # From Palette instance
974
+ if isinstance(source, Palette):
975
+ colors = source.get_hex_colors()
976
+ cmap_name = name or getattr(source, "name", "custom")
977
+ return cls._from_colors(colors, cmap_name, N, discrete)
978
+
979
+ # From list of colors
980
+ if isinstance(source, (list, tuple)) and not isinstance(source, str):
981
+ cmap_name = name or "custom"
982
+ return cls._from_colors(source, cmap_name, N, discrete)
983
+
984
+ # From string name - try loading
985
+ if isinstance(source, str):
986
+ cmap_name = name or source
987
+ return cls._load(source, cmap_name, N)
988
+
989
+ raise TypeError(f"Cannot create colormap from {type(source)}")
990
+
991
+ @classmethod
992
+ def _wrap_mpl_colormap(cls, cmap, N=256):
993
+ """Wrap a matplotlib colormap in our extended class."""
994
+ if isinstance(cmap, mcolors.ListedColormap):
995
+ new_cmap = DiscreteColormap(cmap.colors, name=cmap.name)
996
+ return new_cmap
997
+ else:
998
+ # Sample and recreate as ContinuousColormap
999
+ colors = [cmap(i / (N - 1)) for i in range(N)]
1000
+ return ContinuousColormap.from_list(cmap.name, colors, N=N)
1001
+
1002
+ @classmethod
1003
+ def _from_colors(cls, colors, name, N, discrete):
1004
+ """Create colormap from a list of colors."""
1005
+ normalized = [mcolors.to_rgba(c) for c in colors]
1006
+ if discrete:
1007
+ return DiscreteColormap(normalized, name=name)
1008
+ return ContinuousColormap.from_list(name, normalized, N=N)
1009
+
1010
+ @classmethod
1011
+ def _load(cls, name, cmap_name, N):
1012
+ """Load colormap by name (matplotlib, scientific_color_maps, or cmap package)."""
1013
+ # Try matplotlib first
1014
+ try:
1015
+ mpl_cmap = plt.get_cmap(name)
1016
+ return cls._wrap_mpl_colormap(mpl_cmap, N)
1017
+ except ValueError:
1018
+ pass
1019
+
1020
+ # Try scientific colormaps
1021
+ file_path = cls._find_cmap_file(name)
1022
+ if file_path:
1023
+ colors = cls._load_colors_from_file(file_path)
1024
+ return ContinuousColormap.from_list(cmap_name, colors, N=N)
1025
+
1026
+ # Try cmap package
1027
+ if HAS_CMAP:
1028
+ try:
1029
+ cmap_obj = cmap_lib.Colormap(name)
1030
+ mpl_cmap = cmap_obj.to_mpl()
1031
+ return cls._wrap_mpl_colormap(mpl_cmap, N)
1032
+ except Exception:
1033
+ pass
1034
+
1035
+ # Not found
1036
+ available = cls.list_available()
1037
+ all_names = [n for names in available.values() for n in names]
1038
+ hint = ""
1039
+ if ":" in name and not HAS_CMAP:
1040
+ hint = (
1041
+ f" Namespaced colormaps like '{name}' require the 'cmap' "
1042
+ f"package (pip install cmap)."
1043
+ )
1044
+ raise ValueError(
1045
+ f"Colormap '{name}' not found.{hint} "
1046
+ f"Available: {', '.join(all_names[:10])}..."
1047
+ )
1048
+
1049
+ @classmethod
1050
+ def _find_cmap_file(cls, name):
1051
+ """Find the file path for a scientific colormap."""
1052
+ for cmap_type in cls.CMAP_TYPES:
1053
+ file_path = os.path.join(cls.CMAP_BASE_PATH, cmap_type, f"{name}.txt")
1054
+ if os.path.exists(file_path):
1055
+ return file_path
1056
+ return None
1057
+
1058
+ @classmethod
1059
+ def _load_colors_from_file(cls, file_path):
1060
+ """Load RGB colors from a text file (values in 0-1 range)."""
1061
+ colors = []
1062
+ with open(file_path, "r") as f:
1063
+ for line in f:
1064
+ parts = line.strip().split()
1065
+ if len(parts) == 3:
1066
+ colors.append(tuple(float(v) for v in parts))
1067
+ return colors
1068
+
1069
+ @classmethod
1070
+ def list_available(cls, cmap_type=None, source='all'):
1071
+ """
1072
+ List available colormaps by type and source.
1073
+
1074
+ Parameters:
1075
+ cmap_type (str): 'sequential', 'diverging', 'multisequential', 'cyclic',
1076
+ 'qualitative', or None for all.
1077
+ source (str): 'all' (default), 'Crameri' (scientific folder),
1078
+ 'matplotlib', or 'cmap' (cmap package catalog).
1079
+
1080
+ Returns:
1081
+ dict or list: Dictionary mapping cmap_type to list of colormap names, or list for a single type.
1082
+ """
1083
+ mpl_colormaps = set(plt.colormaps())
1084
+ MPL_DEFAULTS = {
1085
+ 'sequential': [
1086
+ 'viridis', 'plasma', 'inferno', 'magma', 'cividis', 'Greys', 'Purples', 'Blues', 'Greens', 'Oranges', 'Reds',
1087
+ 'YlOrBr', 'YlOrRd', 'OrRd', 'PuRd', 'RdPu', 'BuPu', 'GnBu', 'PuBu', 'YlGnBu', 'PuBuGn', 'BuGn', 'YlGn'
1088
+ ],
1089
+ 'diverging': [
1090
+ 'PiYG', 'PRGn', 'BrBG', 'PuOr', 'RdGy', 'RdBu', 'RdYlBu', 'RdYlGn', 'Spectral', 'coolwarm', 'bwr', 'seismic'
1091
+ ],
1092
+ 'cyclic': [
1093
+ 'twilight', 'twilight_shifted', 'hsv'
1094
+ ],
1095
+ 'multisequential': [],
1096
+ 'qualitative': [],
1097
+ }
1098
+
1099
+ # Mapping from bsplot categories to cmap package categories
1100
+ CMAP_CATEGORY_MAP = {
1101
+ 'sequential': 'sequential',
1102
+ 'diverging': 'diverging',
1103
+ 'cyclic': 'cyclic',
1104
+ 'qualitative': 'qualitative',
1105
+ 'multisequential': None, # cmap package has no multisequential category
1106
+ }
1107
+
1108
+ def get_crameri_names(cmaptype):
1109
+ type_path = os.path.join(cls.CMAP_BASE_PATH, cmaptype)
1110
+ if os.path.exists(type_path):
1111
+ names = [
1112
+ os.path.splitext(f)[0]
1113
+ for f in os.listdir(type_path)
1114
+ if f.endswith(".txt") and not re.search(r"(10|25|50|HEX)", f)
1115
+ ]
1116
+ return sorted(names)
1117
+ return []
1118
+
1119
+ def get_mpl_names(cmaptype):
1120
+ return [n for n in MPL_DEFAULTS.get(cmaptype, []) if n in mpl_colormaps]
1121
+
1122
+ def get_cmap_names(cmaptype):
1123
+ """Get colormap names from the cmap package catalog."""
1124
+ if not HAS_CMAP:
1125
+ return []
1126
+ cmap_category = CMAP_CATEGORY_MAP.get(cmaptype)
1127
+ if cmap_category is None:
1128
+ return []
1129
+ try:
1130
+ catalog = cmap_lib.Colormap.catalog()
1131
+ names = sorted(catalog.unique_keys(
1132
+ prefer_short_names=False,
1133
+ categories=(cmap_category,),
1134
+ ))
1135
+ return names
1136
+ except Exception:
1137
+ return []
1138
+
1139
+ types_to_check = [cmap_type] if cmap_type else list(cls.CMAP_TYPES)
1140
+
1141
+ if source == 'all':
1142
+ if cmap_type is None:
1143
+ available = {}
1144
+ for cmaptype in types_to_check:
1145
+ available[cmaptype] = get_mpl_names(cmaptype) + get_crameri_names(cmaptype)
1146
+ return available
1147
+ else:
1148
+ return get_mpl_names(cmap_type) + get_crameri_names(cmap_type)
1149
+ elif source.lower() == 'crameri':
1150
+ if cmap_type is None:
1151
+ available = {}
1152
+ for cmaptype in types_to_check:
1153
+ available[cmaptype] = get_crameri_names(cmaptype)
1154
+ return available
1155
+ else:
1156
+ return get_crameri_names(cmap_type)
1157
+ elif source.lower() == 'matplotlib':
1158
+ if cmap_type is None:
1159
+ available = {}
1160
+ for cmaptype in types_to_check:
1161
+ available[cmaptype] = get_mpl_names(cmaptype)
1162
+ return available
1163
+ else:
1164
+ return get_mpl_names(cmap_type)
1165
+ elif source.lower() == 'cmap':
1166
+ if cmap_type is None:
1167
+ available = {}
1168
+ for cmaptype in types_to_check:
1169
+ available[cmaptype] = get_cmap_names(cmaptype)
1170
+ return available
1171
+ else:
1172
+ return get_cmap_names(cmap_type)
1173
+ else:
1174
+ raise ValueError(f"Unknown source '{source}'. Use 'all', 'Crameri', 'matplotlib', or 'cmap'.")
1175
+
1176
+ @classmethod
1177
+ def load(cls, name, N=256):
1178
+ """
1179
+ Load a colormap by name.
1180
+
1181
+ Parameters:
1182
+ name (str): Colormap name (matplotlib or scientific).
1183
+ N (int): Number of quantization levels.
1184
+
1185
+ Returns:
1186
+ ContinuousColormap or DiscreteColormap
1187
+ """
1188
+ return cls(name, N=N)
1189
+
1190
+ @classmethod
1191
+ def from_colors(cls, colors, name="custom", discrete=False, N=256):
1192
+ """
1193
+ Create a colormap from a list of colors.
1194
+
1195
+ Parameters:
1196
+ colors (list): List of colors (hex strings, RGB tuples, or named colors).
1197
+ name (str): Name for the colormap.
1198
+ discrete (bool): If True, create a DiscreteColormap.
1199
+ N (int): Number of quantization levels (for continuous).
1200
+
1201
+ Returns:
1202
+ ContinuousColormap or DiscreteColormap
1203
+ """
1204
+ return cls(colors, name=name, N=N, discrete=discrete)
1205
+
1206
+ @classmethod
1207
+ def from_palette(cls, palette, discrete=False, N=256):
1208
+ """
1209
+ Create a colormap from a Palette instance.
1210
+
1211
+ Parameters:
1212
+ palette (Palette): A Palette instance.
1213
+ discrete (bool): If True, create a DiscreteColormap.
1214
+ N (int): Number of quantization levels (for continuous).
1215
+
1216
+ Returns:
1217
+ ContinuousColormap or DiscreteColormap
1218
+ """
1219
+ return cls(palette, N=N, discrete=discrete)
1220
+
1221
+ @classmethod
1222
+ def concatenate(cls, cmaps, ratios=None, name="concatenated", N=256):
1223
+ """
1224
+ Concatenate multiple colormaps into a single continuous colormap.
1225
+
1226
+ Parameters:
1227
+ cmaps (list): List of colormaps (names, Colormap instances, or matplotlib colormaps).
1228
+ ratios (list): Relative sizes of each segment. Defaults to equal sizes.
1229
+ E.g. [1, 2, 1] gives the middle cmap twice the space.
1230
+ name (str): Name for the resulting colormap.
1231
+ N (int): Number of quantization levels.
1232
+
1233
+ Returns:
1234
+ ContinuousColormap: A new colormap combining all inputs.
1235
+
1236
+ Example:
1237
+ >>> purple = Colormap("viridis").truncate(0.0, 0.2)
1238
+ >>> yellow = Colormap("viridis").truncate(0.8, 1.0)
1239
+ >>> combined = Colormap.concatenate([purple, yellow])
1240
+ """
1241
+ if ratios is None:
1242
+ ratios = [1.0] * len(cmaps)
1243
+ ratios = np.array(ratios, dtype=float)
1244
+ ratios /= ratios.sum()
1245
+
1246
+ # Resolve all cmaps
1247
+ resolved = []
1248
+ for c in cmaps:
1249
+ if isinstance(c, str):
1250
+ resolved.append(cls(c))
1251
+ elif isinstance(c, mcolors.Colormap):
1252
+ resolved.append(c)
1253
+ else:
1254
+ raise TypeError(f"Cannot interpret {type(c)} as a colormap.")
1255
+
1256
+ # Sample colors from each cmap proportionally
1257
+ all_colors = []
1258
+ for cmap_obj, ratio in zip(resolved, ratios):
1259
+ n_samples = max(int(round(ratio * N)), 2)
1260
+ t = np.linspace(0, 1, n_samples)
1261
+ all_colors.extend([cmap_obj(v) for v in t])
1262
+
1263
+ return ContinuousColormap.from_list(name, all_colors, N=N)
1264
+
1265
+ @classmethod
1266
+ def make_diverging(cls, low, high, center="white", blend_width=0.3,
1267
+ low_range=None, high_range=None, name="diverging", N=256):
1268
+ """
1269
+ Create a diverging colormap from two color sources with a blended center.
1270
+
1271
+ Samples colors from the low end and high end, then blends them towards
1272
+ a center color (e.g. white) to create a smooth diverging colormap.
1273
+
1274
+ Parameters:
1275
+ low: Colormap for the low (left) side. Can be a name (str), a Colormap,
1276
+ or a matplotlib colormap.
1277
+ high: Colormap for the high (right) side.
1278
+ center: Center color to blend towards (default: "white"). Any valid
1279
+ matplotlib color specification.
1280
+ blend_width (float): Fraction of each half that blends towards the center
1281
+ color. 0 = no blending (hard transition), 1 = blend
1282
+ across the entire half. Default: 0.3.
1283
+ low_range (tuple): (start, stop) range to sample from the low colormap.
1284
+ Default: (0, 1).
1285
+ high_range (tuple): (start, stop) range to sample from the high colormap.
1286
+ Default: (0, 1). Note: this is sampled in reverse
1287
+ so the high colormap's stop is at the outer edge.
1288
+ name (str): Name for the resulting colormap.
1289
+ N (int): Number of quantization levels.
1290
+
1291
+ Returns:
1292
+ ContinuousColormap: A diverging colormap.
1293
+
1294
+ Example:
1295
+ >>> # Diverging viridis: purple ← white → yellow
1296
+ >>> cmap = Colormap.make_diverging(
1297
+ ... "viridis", "viridis",
1298
+ ... low_range=(0, 0.2),
1299
+ ... high_range=(0.8, 1.0),
1300
+ ... center="white",
1301
+ ... blend_width=0.4,
1302
+ ... )
1303
+
1304
+ >>> # Diverging from two different colormaps
1305
+ >>> cmap = Colormap.make_diverging("Blues_r", "Reds")
1306
+
1307
+ >>> # Using existing Colormap objects
1308
+ >>> blue = Colormap("viridis").truncate(0.0, 0.3)
1309
+ >>> red = Colormap("inferno").truncate(0.3, 0.8)
1310
+ >>> cmap = Colormap.make_diverging(blue, red, center="#f0f0f0")
1311
+ """
1312
+ # Resolve colormaps
1313
+ if isinstance(low, str):
1314
+ low = cls(low)
1315
+ if isinstance(high, str):
1316
+ high = cls(high)
1317
+
1318
+ low_start, low_stop = low_range if low_range else (0.0, 1.0)
1319
+ high_start, high_stop = high_range if high_range else (0.0, 1.0)
1320
+
1321
+ half = N // 2
1322
+ center_rgba = np.array(mcolors.to_rgba(center))
1323
+
1324
+ # Sample low side
1325
+ t_low = np.linspace(low_start, low_stop, half)
1326
+ low_colors = np.array([low(v) for v in t_low])
1327
+
1328
+ # Sample high side (reversed so center is at the inner edge)
1329
+ t_high = np.linspace(high_stop, high_start, half)
1330
+ high_colors = np.array([high(v) for v in t_high])
1331
+
1332
+ # Stack: low_colors | high_colors
1333
+ colors = np.vstack([low_colors, high_colors])
1334
+
1335
+ # Blend towards center color
1336
+ if blend_width > 0:
1337
+ n_total = len(colors)
1338
+ mid = n_total // 2
1339
+ for i in range(n_total):
1340
+ dist = abs(i - mid) / mid # 0 at center, 1 at edges
1341
+ if dist < blend_width:
1342
+ w = 1.0 - (dist / blend_width) # 1 at center, 0 at blend edge
1343
+ colors[i, :] = colors[i, :] * (1.0 - w) + center_rgba * w
1344
+
1345
+ return ContinuousColormap.from_list(name, colors.tolist(), N=N)
1346
+
1347
+
1348
+ def get_cmap(name="batlow", N=256):
1349
+ """
1350
+ Get a colormap by name.
1351
+
1352
+ Parameters:
1353
+ name (str): Name of the colormap (matplotlib or scientific).
1354
+ N (int): Number of quantization levels.
1355
+
1356
+ Returns:
1357
+ matplotlib.colors.Colormap: A matplotlib colormap.
1358
+ """
1359
+ return Colormap(name, N=N)
1360
+
1361
+
1362
+ def create_cmap_from_lut(lut_file_path, include_zero=False):
1363
+ lut = pd.read_csv(
1364
+ lut_file_path,
1365
+ comment="#",
1366
+ sep=r"\s+",
1367
+ header=None,
1368
+ names=["index", "R", "G", "B", "A"],
1369
+ )
1370
+ lut["A"] = lut.apply(
1371
+ lambda row: 255 if row["index"] not in ["NONE", "Unknown"] else row["A"], axis=1
1372
+ )
1373
+ rgba_colors = lut[["R", "G", "B", "A"]].div(255).to_numpy()
1374
+ if not include_zero:
1375
+ rgba_colors = rgba_colors[1:]
1376
+ cmap = mcolors.ListedColormap(rgba_colors)
1377
+
1378
+ return cmap
1379
+
1380
+
1381
+ def cmap_to_colors(
1382
+ cmap="viridis", n=8, start=0.0, stop=1.0, endpoint=True, reverse=False, to_hex=False
1383
+ ):
1384
+ """
1385
+ Sample a continuous Matplotlib colormap into a list of n colors.
1386
+
1387
+ Parameters:
1388
+ cmap (str or Colormap): Name or instance of a Matplotlib colormap.
1389
+ n (int): Number of samples.
1390
+ start (float): Start of the sampling range in [0, 1].
1391
+ stop (float): End of the sampling range in [0, 1].
1392
+ endpoint (bool): Include the stop value.
1393
+ reverse (bool): Reverse the order of colors.
1394
+ to_hex (bool): Return colors as hex strings instead of RGBA.
1395
+
1396
+ Returns:
1397
+ list: List of colors (RGBA tuples or hex strings).
1398
+ """
1399
+ if isinstance(cmap, str):
1400
+ cmap = Colormap(cmap)
1401
+ t = np.linspace(start, stop, int(n), endpoint=endpoint)
1402
+ if reverse:
1403
+ t = t[::-1]
1404
+ cols = [cmap(v) for v in t]
1405
+ if to_hex:
1406
+ cols = [mcolors.to_hex(c) for c in cols]
1407
+ return cols
1408
+
1409
+
1410
+ def make_cycler_from_cmap(
1411
+ cmap="viridis",
1412
+ n=8,
1413
+ start=0.0,
1414
+ stop=1.0,
1415
+ endpoint=True,
1416
+ reverse=False,
1417
+ to_hex=False,
1418
+ return_hex=False,
1419
+ ):
1420
+ """
1421
+ Build a Matplotlib prop cycler from a continuous colormap.
1422
+
1423
+ Parameters mirror cmap_to_colors. If return_hex=True, return the list of colors
1424
+ (hex if to_hex or return_hex is True) instead of a cycler.
1425
+
1426
+ Returns:
1427
+ cycler | list: cycler('color', colors) by default, or list of colors when return_hex=True.
1428
+ """
1429
+ colors = cmap_to_colors(
1430
+ cmap=cmap,
1431
+ n=n,
1432
+ start=start,
1433
+ stop=stop,
1434
+ endpoint=endpoint,
1435
+ reverse=reverse,
1436
+ to_hex=(to_hex or return_hex),
1437
+ )
1438
+ if return_hex:
1439
+ return colors
1440
+ return cycler("color", colors)
1441
+
1442
+
1443
+ def discrete_cmap_from_continuous(
1444
+ cmap="viridis", n=8, start=0.0, stop=1.0, endpoint=True, reverse=False
1445
+ ):
1446
+ """
1447
+ Create a discrete ListedColormap from a continuous colormap.
1448
+
1449
+ Parameters:
1450
+ cmap (str or Colormap): Name or instance of a Matplotlib colormap.
1451
+ n (int): Number of discrete colors.
1452
+ start (float): Start of the sampling range in [0, 1].
1453
+ stop (float): End of the sampling range in [0, 1].
1454
+ endpoint (bool): Include the stop value.
1455
+ reverse (bool): Reverse the order of colors.
1456
+
1457
+ Returns:
1458
+ ListedColormap: A discrete ListedColormap instance.
1459
+ """
1460
+ colors = cmap_to_colors(
1461
+ cmap=cmap,
1462
+ n=n,
1463
+ start=start,
1464
+ stop=stop,
1465
+ endpoint=endpoint,
1466
+ reverse=reverse,
1467
+ )
1468
+ return mcolors.ListedColormap(colors)
1469
+
1470
+
1471
+ # ---------------------------------------------------------------------------
1472
+ # Colormap accessibility — computed from actual colormap data
1473
+ # ---------------------------------------------------------------------------
1474
+
1475
+ def _cmap_has_tag(name, tag):
1476
+ """Check whether a *cmap*-package colormap carries a given catalog tag.
1477
+
1478
+ Uses the live catalog so that newly added colormaps or updated tags
1479
+ are picked up automatically.
1480
+
1481
+ Recognised tags (as of cmap 0.7):
1482
+ 'colorblind', 'uniform', 'protanopia', 'deuteranopia', 'tritanopia'
1483
+ """
1484
+ if not HAS_CMAP:
1485
+ return False
1486
+ try:
1487
+ import warnings as _w
1488
+ with _w.catch_warnings():
1489
+ _w.simplefilter("ignore")
1490
+ entry = cmap_lib.Colormap.catalog()[name]
1491
+ return tag in entry.tags
1492
+ except (KeyError, Exception):
1493
+ return False
1494
+
1495
+
1496
+ def _luminance_profile(cmap_obj, n_samples=32):
1497
+ """Return the luminance array for a colormap, sampled at *n_samples*
1498
+ evenly-spaced points."""
1499
+ t = np.linspace(0, 1, n_samples)
1500
+ rgba = np.array([cmap_obj(v) for v in t])
1501
+ # Relative luminance (Rec. 709)
1502
+ return 0.2126 * rgba[:, 0] + 0.7152 * rgba[:, 1] + 0.0722 * rgba[:, 2]
1503
+
1504
+
1505
+ def _luminance_is_monotonic(cmap_obj, n_samples=32, tolerance=0.04):
1506
+ """Return True if a colormap's perceived luminance is approximately
1507
+ monotonic (either increasing or decreasing).
1508
+
1509
+ This is the *computed* definition of print-friendliness: if the
1510
+ luminance changes monotonically, the colormap reproduces well in
1511
+ greyscale.
1512
+
1513
+ Parameters
1514
+ ----------
1515
+ cmap_obj : matplotlib colormap
1516
+ Any callable that maps [0, 1] → RGBA.
1517
+ n_samples : int
1518
+ Number of points to sample along the colourmap.
1519
+ tolerance : float
1520
+ Fraction of the luminance range by which individual steps may
1521
+ violate strict monotonicity (accounts for small wiggles in
1522
+ otherwise perceptually uniform maps).
1523
+ """
1524
+ lum = _luminance_profile(cmap_obj, n_samples)
1525
+ diffs = np.diff(lum)
1526
+ lum_range = lum.max() - lum.min()
1527
+ if lum_range < 0.05:
1528
+ # Near-constant luminance (e.g. pure-hue cyclic maps) — not useful
1529
+ # in greyscale.
1530
+ return False
1531
+
1532
+ eps = tolerance * lum_range
1533
+ # Check approximately increasing OR approximately decreasing
1534
+ return bool(np.all(diffs > -eps) or np.all(diffs < eps))
1535
+
1536
+
1537
+ def _luminance_has_structure(cmap_obj, n_samples=32, tolerance=0.04):
1538
+ """Return True if a colormap encodes information in lightness, making
1539
+ it robust under colour-vision deficiency.
1540
+
1541
+ Accepts:
1542
+ - Monotonic luminance (sequential maps)
1543
+ - V-shaped or Λ-shaped luminance (diverging maps where each half is
1544
+ monotonic in opposite directions)
1545
+ """
1546
+ lum = _luminance_profile(cmap_obj, n_samples)
1547
+ lum_range = lum.max() - lum.min()
1548
+ if lum_range < 0.05:
1549
+ return False
1550
+
1551
+ # 1. Fully monotonic?
1552
+ diffs = np.diff(lum)
1553
+ eps = tolerance * lum_range
1554
+ if np.all(diffs > -eps) or np.all(diffs < eps):
1555
+ return True
1556
+
1557
+ # 2. Diverging structure: split at midpoint — each half monotonic
1558
+ # in opposite directions (V or Λ shape).
1559
+ mid = n_samples // 2
1560
+ left = lum[:mid + 1]
1561
+ right = lum[mid:]
1562
+ left_diffs = np.diff(left)
1563
+ right_diffs = np.diff(right)
1564
+ left_range = left.max() - left.min()
1565
+ right_range = right.max() - right.min()
1566
+
1567
+ if left_range < 0.03 or right_range < 0.03:
1568
+ return False
1569
+
1570
+ eps_l = tolerance * left_range
1571
+ eps_r = tolerance * right_range
1572
+
1573
+ left_inc = np.all(left_diffs > -eps_l)
1574
+ left_dec = np.all(left_diffs < eps_l)
1575
+ right_inc = np.all(right_diffs > -eps_r)
1576
+ right_dec = np.all(right_diffs < eps_r)
1577
+
1578
+ # V shape: decreasing then increasing, or Λ: increasing then decreasing
1579
+ if (left_dec and right_inc) or (left_inc and right_dec):
1580
+ return True
1581
+
1582
+ return False
1583
+
1584
+
1585
+ def _is_colorblind_safe(name, cmap_obj):
1586
+ """Heuristic: a colormap is considered CVD-safe if
1587
+
1588
+ 1. the cmap catalog tags it as ``'colorblind'``, **or**
1589
+ 2. its luminance has structure (monotonic for sequential, V/Λ-shaped
1590
+ for diverging) — meaning information is encoded in lightness, not
1591
+ just hue, which is inherently robust under CVD.
1592
+ """
1593
+ # Catalog tags (dynamic)
1594
+ if ":" in name and _cmap_has_tag(name, "colorblind"):
1595
+ return True
1596
+ # Compute: structured luminance ≈ CVD-robust
1597
+ return _luminance_has_structure(cmap_obj)
1598
+
1599
+
1600
+ def _is_print_friendly(name, cmap_obj):
1601
+ """Heuristic: a colormap is print-friendly if its luminance profile is
1602
+ approximately monotonic, meaning it survives greyscale conversion."""
1603
+ return _luminance_is_monotonic(cmap_obj)
1604
+
1605
+
1606
+ # Human-friendly data description → internal cmap type
1607
+ _DATA_TYPE_MAP = {
1608
+ "ordered": "sequential", "sequential": "sequential",
1609
+ "centered": "diverging", "diverging": "diverging",
1610
+ "periodic": "cyclic", "cyclic": "cyclic",
1611
+ "categorical": "qualitative", "qualitative": "qualitative",
1612
+ "multirange": "multisequential", "multisequential": "multisequential",
1613
+ }
1614
+
1615
+ # ---------------------------------------------------------------------------
1616
+ # Interactive wizard prompts
1617
+ # ---------------------------------------------------------------------------
1618
+ def _wizard_prompts():
1619
+ """Run the interactive wizard and return resolved keyword arguments."""
1620
+ print("━" * 50)
1621
+ print(" bsplot Colormap Explorer")
1622
+ print("━" * 50)
1623
+ print()
1624
+
1625
+ # ── Q1 Data character ──────────────────────────────
1626
+ print("What kind of data are you visualising?")
1627
+ print(" 1 Ordered / sequential (intensity, temperature, counts)")
1628
+ print(" 2 Centered / diverging (anomalies, differences, ±values)")
1629
+ print(" 3 Periodic / cyclic (phase, angles, time of day)")
1630
+ print(" 4 Categorical (groups, labels, brain regions)")
1631
+ print(" 5 Multi-range (land / ocean elevation)")
1632
+ c = input("\n▸ Enter number [1-5]: ").strip()
1633
+ data = {
1634
+ "1": "ordered", "2": "centered", "3": "periodic",
1635
+ "4": "categorical", "5": "multirange",
1636
+ }.get(c, "ordered")
1637
+ print()
1638
+
1639
+ # ── Q2 Continuous vs discrete ──────────────────────
1640
+ n_categories = None
1641
+ print("Is your data continuous or discrete?")
1642
+ print(" 1 Continuous (smooth gradient)")
1643
+ print(" 2 Discrete (fixed number of categories)")
1644
+ c = input("\n▸ Enter number [1-2]: ").strip()
1645
+ if c == "2":
1646
+ try:
1647
+ n_categories = int(input(" How many categories? ").strip())
1648
+ except ValueError:
1649
+ n_categories = None
1650
+ print()
1651
+
1652
+ # ── Q3 Colorblind safety ───────────────────────────
1653
+ cb = input("Must be colorblind-safe? [y/N]: ").strip().lower()
1654
+ colorblind_safe = cb in ("y", "yes")
1655
+ print()
1656
+
1657
+ # ── Q4 Print / greyscale ───────────────────────────
1658
+ pf = input("Needs to work in greyscale print? [y/N]: ").strip().lower()
1659
+ print_friendly = pf in ("y", "yes")
1660
+ print()
1661
+
1662
+ # ── Q5 Preview style ──────────────────────────────
1663
+ print("How would you like to preview the colormaps?")
1664
+ print(" 1 Gradient bars")
1665
+ print(" 2 Heatmap / matrix")
1666
+ print(" 3 Brain surface")
1667
+ c = input("\n▸ Enter number [1-3]: ").strip()
1668
+ preview = {"1": "gradient", "2": "matrix", "3": "brain"}.get(c, "gradient")
1669
+ print()
1670
+
1671
+ return data, colorblind_safe, n_categories, print_friendly, preview
1672
+
1673
+
1674
+ # ---------------------------------------------------------------------------
1675
+ # Preview renderers
1676
+ # ---------------------------------------------------------------------------
1677
+ def _preview_gradient(names, n_categories, figsize, cmap_type, source):
1678
+ """Gradient-bar gallery."""
1679
+ n = len(names)
1680
+ if figsize is None:
1681
+ figsize = (10, max(2, 0.45 * n))
1682
+
1683
+ fig, axes = plt.subplots(n, 1, figsize=figsize)
1684
+ if n == 1:
1685
+ axes = [axes]
1686
+
1687
+ if n_categories:
1688
+ gradient = np.linspace(0, 1, n_categories).reshape(1, -1)
1689
+ gradient = np.repeat(gradient, 20, axis=0)
1690
+ else:
1691
+ gradient = np.linspace(0, 1, 256).reshape(1, -1)
1692
+
1693
+ for ax, name in zip(axes, names):
1694
+ try:
1695
+ cmap = Colormap(name, N=n_categories) if n_categories else Colormap(name)
1696
+ except Exception:
1697
+ ax.set_visible(False)
1698
+ continue
1699
+ ax.imshow(gradient, aspect="auto", cmap=cmap, interpolation="nearest")
1700
+ ax.set_yticks([])
1701
+ ax.set_xticks([])
1702
+ ax.set_ylabel(name, rotation=0, ha="right", va="center", fontsize=8)
1703
+
1704
+ title = f"Colormaps — {cmap_type}"
1705
+ if source != "all":
1706
+ title += f" ({source})"
1707
+ fig.suptitle(title, fontsize=12, fontweight="bold")
1708
+ plt.tight_layout()
1709
+ plt.show()
1710
+
1711
+
1712
+ def _preview_matrix(names, n_categories, figsize):
1713
+ """Heatmap gallery on synthetic 2-D data."""
1714
+ ncols = min(4, len(names))
1715
+ nrows = (len(names) + ncols - 1) // ncols
1716
+ if figsize is None:
1717
+ figsize = (3.5 * ncols, 3 * nrows)
1718
+
1719
+ x = np.linspace(-3, 3, 120)
1720
+ X, Y = np.meshgrid(x, x)
1721
+ Z = np.sin(X) * np.cos(Y)
1722
+
1723
+ fig, axs = plt.subplots(nrows, ncols, figsize=figsize, constrained_layout=True)
1724
+ axs = np.atleast_1d(axs).ravel()
1725
+ for ax in axs:
1726
+ ax.axis("off")
1727
+
1728
+ for ax, name in zip(axs, names):
1729
+ try:
1730
+ cmap = Colormap(name, N=n_categories) if n_categories else Colormap(name)
1731
+ except Exception:
1732
+ continue
1733
+ im = ax.imshow(Z, cmap=cmap, origin="lower")
1734
+ ax.set_title(name, fontsize=8)
1735
+ fig.colorbar(im, ax=ax, shrink=0.7)
1736
+
1737
+ fig.suptitle("Colormap preview — heatmap", fontsize=12, fontweight="bold")
1738
+ plt.show()
1739
+
1740
+
1741
+ def _preview_brain(names, n_categories, figsize):
1742
+ """Brain-surface gallery."""
1743
+ try:
1744
+ import random as _random
1745
+ import templateflow
1746
+ import nibabel as nib
1747
+ from bsplot import surface as bssurface, style as _style
1748
+ except ImportError:
1749
+ print(
1750
+ "Brain preview requires 'nibabel' and 'templateflow'. "
1751
+ "Falling back to gradient bars."
1752
+ )
1753
+ _preview_gradient(names, n_categories, figsize, "—", "all")
1754
+ return
1755
+
1756
+ fsLR_lh = templateflow.api.get(
1757
+ template="fsLR", density="32k", suffix="inflated", hemi="L",
1758
+ )
1759
+ lh = nib.load(fsLR_lh)
1760
+ vertices, _ = lh.agg_data()
1761
+ centre = _random.choice(range(len(vertices)))
1762
+ distances = np.linalg.norm(vertices - vertices[centre], axis=1)
1763
+ overlay = np.exp(-0.5 * (distances / 100.0) ** 2)
1764
+
1765
+ ncols = min(4, len(names))
1766
+ nrows = (len(names) + ncols - 1) // ncols
1767
+ if figsize is None:
1768
+ figsize = (3.5 * ncols, 2.5 * nrows)
1769
+
1770
+ fig, axs = plt.subplots(nrows, ncols, figsize=figsize, constrained_layout=True)
1771
+ axs = np.atleast_1d(axs).ravel()
1772
+ for ax in axs:
1773
+ ax.axis("off")
1774
+
1775
+ for ax, name in zip(axs, names):
1776
+ try:
1777
+ cmap = Colormap(name, N=n_categories) if n_categories else Colormap(name)
1778
+ except Exception:
1779
+ continue
1780
+ mappable = bssurface.plot_surf(
1781
+ surface="fsLR", overlay=overlay,
1782
+ ax=ax, view="lateral", hemi="lh", cmap=cmap,
1783
+ )
1784
+ fig.colorbar(mappable, ax=ax, shrink=0.6)
1785
+ ax.set_title(name, fontsize=8)
1786
+
1787
+ _style.format_fig(fig)
1788
+ plt.show()
1789
+
1790
+
1791
+ # ---------------------------------------------------------------------------
1792
+ # Main explorer
1793
+ # ---------------------------------------------------------------------------
1794
+ def explore_colormaps(
1795
+ data=None,
1796
+ colorblind_safe=None,
1797
+ n_categories=None,
1798
+ print_friendly=None,
1799
+ preview=None,
1800
+ source="all",
1801
+ search=None,
1802
+ max_display=24,
1803
+ figsize=None,
1804
+ ):
1805
+ """Guided colormap explorer — find the best colormap for your plotting
1806
+ problem.
1807
+
1808
+ Call with **no arguments** for an interactive wizard that walks you through
1809
+ your requirements step by step, or pass keyword arguments directly for
1810
+ scripted use.
1811
+
1812
+ Parameters
1813
+ ----------
1814
+ data : str, optional
1815
+ Describes what you are plotting. Human-friendly aliases are supported:
1816
+
1817
+ ============== ================ ===================================
1818
+ Alias Internal type When to use
1819
+ ============== ================ ===================================
1820
+ ``'ordered'`` sequential intensity, temperature, counts
1821
+ ``'centered'`` diverging anomalies, correlation, differences
1822
+ ``'periodic'`` cyclic phase, angles, time of day
1823
+ ``'categorical'`` qualitative groups, labels, brain regions
1824
+ ``'multirange'`` multisequential land / ocean elevation
1825
+ ============== ================ ===================================
1826
+
1827
+ If *None* (the default), the interactive wizard is launched.
1828
+ colorblind_safe : bool, optional
1829
+ Keep only colormaps that are designed for colour-vision deficiency.
1830
+ All Crameri colormaps, the *viridis* family, and most cmasher / cmocean
1831
+ maps pass this filter. Default *False* (or asked in wizard mode).
1832
+ n_categories : int, optional
1833
+ If set, show discrete colour swatches (``Colormap(name, N=n)``)
1834
+ instead of a smooth gradient. Useful for categorical data.
1835
+ print_friendly : bool, optional
1836
+ Keep only colormaps whose monotonically increasing luminance
1837
+ reproduces well in greyscale print.
1838
+ preview : str, optional
1839
+ How to preview the candidates:
1840
+
1841
+ * ``'gradient'`` — colour gradient bars (default)
1842
+ * ``'matrix'`` — 2-D heatmap on sample data
1843
+ * ``'brain'`` — lateral brain-surface view (requires *nibabel* &
1844
+ *templateflow*)
1845
+ source : str
1846
+ ``'all'``, ``'matplotlib'``, ``'Crameri'``, or ``'cmap'``.
1847
+ search : str, optional
1848
+ Case-insensitive substring filter on colormap names.
1849
+ max_display : int
1850
+ Maximum number of colormaps to show (default 24).
1851
+ figsize : tuple, optional
1852
+ Matplotlib figure size override.
1853
+
1854
+ Returns
1855
+ -------
1856
+ list of str
1857
+ Names of the colormaps that were displayed.
1858
+
1859
+ Examples
1860
+ --------
1861
+ >>> explore_colormaps() # launch wizard
1862
+ >>> explore_colormaps("ordered", colorblind_safe=True)
1863
+ >>> explore_colormaps("centered", preview="brain", max_display=12)
1864
+ >>> explore_colormaps("periodic", source="cmap")
1865
+ """
1866
+ # ── wizard mode when called bare ───────────────────────
1867
+ interactive = data is None
1868
+ if interactive:
1869
+ data, colorblind_safe, n_categories, print_friendly, preview = (
1870
+ _wizard_prompts()
1871
+ )
1872
+
1873
+ # ── defaults for scripted calls ────────────────────────
1874
+ if colorblind_safe is None:
1875
+ colorblind_safe = False
1876
+ if print_friendly is None:
1877
+ print_friendly = False
1878
+ if preview is None:
1879
+ preview = "gradient"
1880
+
1881
+ # ── resolve cmap type ──────────────────────────────────
1882
+ cmap_type = _DATA_TYPE_MAP.get(data, "sequential")
1883
+
1884
+ # ── collect candidates ─────────────────────────────────
1885
+ available = Colormap.list_available(cmap_type=cmap_type, source=source)
1886
+ if isinstance(available, dict):
1887
+ names = [n for v in available.values() for n in v]
1888
+ else:
1889
+ names = list(available)
1890
+
1891
+ # ── filter: colorblind safety (computed) ─────────────────
1892
+ if colorblind_safe:
1893
+ def _cb(n):
1894
+ try:
1895
+ cmap_obj = Colormap(n)
1896
+ return _is_colorblind_safe(n, cmap_obj)
1897
+ except Exception:
1898
+ return False
1899
+ names = [n for n in names if _cb(n)]
1900
+
1901
+ # ── filter: print-friendly (computed) ──────────────────
1902
+ if print_friendly:
1903
+ def _pf(n):
1904
+ try:
1905
+ cmap_obj = Colormap(n)
1906
+ return _is_print_friendly(n, cmap_obj)
1907
+ except Exception:
1908
+ return False
1909
+ names = [n for n in names if _pf(n)]
1910
+
1911
+ # ── filter: keyword search ─────────────────────────────
1912
+ if search:
1913
+ sl = search.lower()
1914
+ names = [n for n in names if sl in n.lower()]
1915
+
1916
+ names = names[:max_display]
1917
+
1918
+ if not names:
1919
+ print("No colormaps match your criteria. Try relaxing some filters.")
1920
+ return []
1921
+
1922
+ # ── render ─────────────────────────────────────────────
1923
+ if preview == "matrix":
1924
+ _preview_matrix(names, n_categories, figsize)
1925
+ elif preview == "brain":
1926
+ _preview_brain(names, n_categories, figsize)
1927
+ else:
1928
+ _preview_gradient(names, n_categories, figsize, cmap_type, source)
1929
+
1930
+ # ── summary ────────────────────────────────────────────
1931
+ print(f"\n{len(names)} colormaps shown. Load with: Colormap('name')")
1932
+ if n_categories:
1933
+ print(f" Discrete version: Colormap('name', N={n_categories})")
1934
+
1935
+ return names