Coverage for chromo_map\color.py: 100%
333 statements
« prev ^ index » next coverage.py v7.6.0, created at 2024-07-31 00:13 -0700
« prev ^ index » next coverage.py v7.6.0, created at 2024-07-31 00:13 -0700
1"""Color module for chromo_map package."""
3import re
4import uuid
5from typing import Tuple
6from textwrap import dedent
7import json
8import base64
9from importlib_resources import files
10from IPython.display import HTML
11from jinja2 import Template
12import numpy as np
13from _plotly_utils import colors as plotly_colors
14from matplotlib.colors import LinearSegmentedColormap as LSC
15from matplotlib.colors import ListedColormap as LC
16from matplotlib.colors import to_rgba, to_rgb
17import matplotlib.pyplot as plt
18import svgwrite
19import palettable
20from palettable.palette import Palette
21from pirrtools import AttrDict, find_instances
22from pirrtools.sequences import lcm
23from bs4 import BeautifulSoup
26def _rgb_c(c):
27 return rf"(?P<{c}>[^,\s]+)"
30_COMMA = r"\s*,\s*"
31_red = _rgb_c("red")
32_grn = _rgb_c("grn")
33_blu = _rgb_c("blu")
34_alp = _rgb_c("alp")
35_rgb_pat = _COMMA.join([_red, _grn, _blu]) + f"({_COMMA}{_alp})?"
36_RGB_PATTERN = re.compile(rf"rgba?\({_rgb_pat}\)")
38_VALID_MPL_COLORS = plt.colormaps()
41def rgba_to_tup(rgbstr):
42 """Convert an RGBA string to a tuple."""
43 match = _RGB_PATTERN.match(rgbstr)
44 if match:
45 gdict = match.groupdict()
46 red = int(gdict["red"])
47 grn = int(gdict["grn"])
48 blu = int(gdict["blu"])
49 if (alp := gdict["alp"]) is not None:
50 alp = float(alp)
51 if not 0 <= alp <= 1:
52 raise ValueError("Alpha must be between 0 and 1.")
53 else:
54 alp = 1
55 return to_rgb(f"#{red:02x}{grn:02x}{blu:02x}") + (alp,)
56 return None
59def hexstr_to_tup(hexstr: str) -> Tuple[int, int, int, int]:
60 """Convert a hex string to a tuple."""
61 try:
62 return to_rgba(hexstr)
63 except ValueError:
64 return None
67def clr_to_tup(clr):
68 """Convert a color to a tuple."""
69 if isinstance(clr, str):
70 return hexstr_to_tup(clr) or rgba_to_tup(clr)
71 if isinstance(clr, (tuple, list)):
72 return clr
73 try:
74 return to_rgba(clr)
75 except ValueError:
76 return None
79class Color:
80 """A class for representing colors.
82 You can pass in a color as a tuple of RGB or
83 RGBA values in which the values are between 0 and 1, a hex string with or without an
84 alpha value, or an RGB or RGBA string in the form `'rgb(r, g, b)'` or
85 `'rgba(r, g, b, a)'`. The alpha value is optional in all cases.
87 You can also pass in another Color object to create a copy of it with an optional
88 new alpha value.
90 The class has properties that return the color in various formats including tuples,
91 hex strings, RGB strings, and RGBA strings.
93 You can also interpolate between two colors by passing in another color and a factor
94 between 0 and 1.
97 Examples
98 --------
100 Simple red color:
102 .. testcode::
104 from chromo_map import Color
105 Color('red')
107 .. html-output::
109 from chromo_map import Color
110 print(Color('red')._repr_html_())
112 Simple red color with alpha:
114 .. testcode::
116 from chromo_map import Color
117 Color('red', 0.5)
119 .. html-output::
121 from chromo_map import Color
122 print(Color('red', 0.5)._repr_html_())
124 Green with hex string:
126 .. testcode::
128 from chromo_map import Color
129 Color('#007f00')
131 .. html-output::
133 from chromo_map import Color
134 print(Color('#007f00')._repr_html_())
136 Blue with RGB string:
138 .. testcode::
140 from chromo_map import Color
141 Color('rgb(0, 0, 255)')
143 .. html-output::
145 from chromo_map import Color
146 print(Color('rgb(0, 0, 255)')._repr_html_())
148 Blue with RGBA string and overidden alpha:
150 .. testcode::
152 from chromo_map import Color
153 Color('rgba(0, 0, 255, 0.5)', 0.75)
155 .. html-output::
157 from chromo_map import Color
158 print(Color('rgba(0, 0, 255, 0.5)', 0.75)._repr_html_())
161 """
163 def __init__(self, clr, alpha=None):
164 if isinstance(clr, Color):
165 self.__dict__.update(clr.__dict__)
166 if alpha is not None:
167 self.a = alpha
168 else:
169 if isinstance(clr, (tuple, list, np.ndarray)):
170 red, grn, blu, *alp = clr
171 if alpha is not None:
172 alp = alpha
173 elif alp:
174 alp = alp[0]
175 else:
176 alp = 1
178 elif isinstance(clr, str):
179 tup = clr_to_tup(clr)
180 if tup is None:
181 raise ValueError("Invalid color input.")
182 red, grn, blu, alp = tup
183 alp = alpha or alp
185 else:
186 raise ValueError(f"Invalid color input '{type(clr).__name__}'.")
188 if all(map(lambda x: 0 <= x <= 1, (red, grn, blu, alp))):
189 self.r = red
190 self.g = grn
191 self.b = blu
192 self.a = alp
193 else:
194 raise ValueError("Color values must be between 0 and 1.")
196 @property
197 def tup(self):
198 """Return the color as a tuple.
200 Returns
201 -------
202 Tuple[float, float, float, float]
203 The color as a tuple of RGBA values between 0 and 1.
205 Examples
206 --------
208 Get the color as a tuple:
210 .. testcode::
212 from chromo_map import Color
213 orange = Color('orange', 0.5)
214 orange.tup
216 .. testoutput::
218 (1.0, 0.6470588235294118, 0.0, 0.5)
220 """
221 return self.r, self.g, self.b, self.a
223 @property
224 def hexatup(self):
225 """Return the color as a tuple of hex values.
227 Returns
228 -------
229 Tuple[int, int, int, int]
230 The color as a tuple of RGBA values between 0 and 255.
232 Examples
233 --------
235 Get the color as a tuple of hex values:
237 .. testcode::
239 from chromo_map import Color
240 orange = Color('orange', 0.5)
241 orange.hexatup
243 .. testoutput::
245 (255, 165, 0, 127)
247 """
248 return tuple(int(x * 255) for x in self.tup)
250 @property
251 def hextup(self):
252 """Return the color as a tuple of hex values.
254 Returns
255 -------
256 Tuple[int, int, int]
257 The color as a tuple of RGB values between 0 and 255.
259 Examples
260 --------
262 Get the color as a tuple of hex values:
264 .. testcode::
266 from chromo_map import Color
267 orange = Color('orange', 0.5)
268 orange.hextup
270 .. testoutput::
272 (255, 165, 0)
274 """
275 return self.hexatup[:3]
277 @property
278 def rgbtup(self):
279 """Return the color as a tuple of RGB values.
281 Returns
282 -------
283 Tuple[int, int, int]
284 The color as a tuple of RGB values between 0 and 255.
286 Examples
287 --------
289 Get the color as a tuple of hex values:
291 .. testcode::
293 from chromo_map import Color
294 orange = Color('orange', 0.5)
295 orange.rgbtup
297 .. testoutput::
299 (255, 165, 0)
301 """
302 return self.hextup
304 @property
305 def rgbatup(self):
306 """Return the color as a tuple of RGBA values.
308 Returns
309 -------
310 Tuple[int, int, int, float]
311 The color as a tuple of RGB values between 0 and 255 and an alpha value
312 between 0 and 1.
314 Examples
315 --------
316 Get the color as a tuple of RGBA values:
318 .. testcode::
320 from chromo_map import Color
321 orange = Color('orange', 0.5)
322 orange.rgbatup
324 .. testoutput::
326 (255, 165, 0, 0.5)
328 """
329 return self.rgbtup + (self.a,)
331 @property
332 def hex(self):
333 """Return the color as a hex string.
335 Returns
336 -------
337 str
338 The color as a hex string.
340 Examples
341 --------
343 Get the color as a hex string:
345 .. testcode::
347 from chromo_map import Color
348 orange = Color('orange', 0.5)
349 orange.hex
351 .. testoutput::
353 '#ffa500'
355 """
356 r, g, b = self.hextup
357 return f"#{r:02x}{g:02x}{b:02x}"
359 @property
360 def hexa(self):
361 """Return the color as a hex string with an alpha value.
363 Returns
364 -------
365 str
366 The color as a hex string with an alpha value.
368 Examples
369 --------
371 Get the color as a hex string with an alpha value:
373 .. testcode::
375 from chromo_map import Color
376 orange = Color('orange', 0.5)
377 orange.hexa
379 .. testoutput::
381 '#ffa50080'
383 """
384 r, g, b, a = self.hexatup
385 return f"#{r:02x}{g:02x}{b:02x}{a:02x}"
387 @property
388 def rgb(self):
389 """Return the color as an RGB string.
391 Returns
392 -------
393 str
394 The color as an RGB string.
396 Examples
397 --------
399 Get the color as an RGB string:
401 .. testcode::
403 from chromo_map import Color
404 orange = Color('orange', 0.5)
405 orange.rgb
407 .. testoutput::
409 'rgb(255, 165, 0)'
411 """
412 r, g, b = self.rgbtup
413 return f"rgb({r}, {g}, {b})"
415 @property
416 def rgba(self):
417 """Return the color as an RGBA string.
419 Returns
420 -------
421 str
422 The color as an RGBA string.
424 Examples
425 --------
427 Get the color as an RGBA string:
429 .. testcode::
431 from chromo_map import Color
432 orange = Color('orange', 0.5)
433 orange.rgba
435 .. testoutput::
437 'rgba(255, 165, 0, 0.5)'
439 """
440 r, g, b, a = self.rgbatup
441 return f"rgba({r}, {g}, {b}, {a:.1f})"
443 def interpolate(self, other, factor):
444 """Interpolate between two colors.
446 Parameters
447 ----------
448 other : Color
449 The other color to interpolate with.
451 factor : float
452 The interpolation factor between 0 and 1.
454 Returns
455 -------
456 Color
457 The interpolated color.
459 Examples
460 --------
461 Interpolate between red and blue:
463 .. testcode::
465 from chromo_map import Color
466 red = Color('red')
467 blue = Color('blue')
469 red.interpolate(blue, 0.5)
471 .. html-output::
473 from chromo_map import Color
474 red = Color('red')
475 blue = Color('blue')
476 print(red.interpolate(blue, 0.5)._repr_html_())
478 """
479 r = self.r + (other.r - self.r) * factor
480 g = self.g + (other.g - self.g) * factor
481 b = self.b + (other.b - self.b) * factor
482 a = self.a + (other.a - self.a) * factor
483 return Color((r, g, b, a))
485 def __or__(self, other):
486 """Interpolate between two colors assuming a factor of 0.5.
488 Parameters
489 ----------
490 other : Color
491 The other color to interpolate with.
493 Returns
494 -------
495 Color
496 The interpolated color.
498 Examples
499 --------
501 Interpolate between red and blue:
503 .. testcode::
505 from chromo_map import Color
506 red = Color('red')
507 blue = Color('blue')
508 red | blue
510 .. html-output::
512 from chromo_map import Color
513 red = Color('red')
514 blue = Color('blue')
515 print((red | blue)._repr_html_())
517 """
518 return self.interpolate(other, 0.5)
520 def _repr_html_(self):
521 random_id = uuid.uuid4().hex
522 style = dedent(
523 f"""\
524 <style>
525 #_{random_id} {{
526 position: relative;
527 display: inline-block;
528 cursor: pointer;
529 background: {self.rgba};
530 width: 2rem; height: 1.5rem;
531 }}
532 #_{random_id}::after {{
533 content: attr(data-tooltip);
534 position: absolute;
535 bottom: 50%;
536 left: 0%;
537 transform: translateY(50%);
538 padding: 0.125rem;
539 white-space: pre;
540 font-size: 0.75rem;
541 font-family: monospace;
542 background: rgba(0, 0, 0, 0.6);
543 backdrop-filter: blur(0.25rem);
544 color: white;
545 border-radius: 0.25rem;
546 opacity: 0;
547 pointer-events: none;
548 transition: opacity 0.1s ease-in-out;
549 z-index: -1;
550 }}
551 #_{random_id}:hover::after {{
552 opacity: 1;
553 z-index: 1;
554 }}
555 </style>
556 """
557 )
558 tooltip = dedent(
559 f"""\
560 RGBA: {self.rgba[5:-1]}
561 HEXA: {self.hexa}\
562 """
563 )
564 return dedent(
565 f"""\
566 <div>
567 {style}
568 <div id="_{random_id}" class="color" data-tooltip="{tooltip}"></div>
569 </div>
570 """
571 )
573 def __eq__(self, other):
574 """Check if two colors are equal.
576 Only checks the result of the tuple property.
578 Parameters
579 ----------
580 other : Color
581 The other color to compare to.
583 Returns
584 -------
585 bool
586 Whether the colors are equal.
587 """
588 return np.isclose(self.tup, other.tup).all()
591class ColorGradient(LSC):
592 """Mimics a matplotlib colormap with a list of colors.
594 I wanted an object that could be used in place of a matplotlib colormap but with a
595 bit of extra functionality. This class allows you to create a gradient from a list
596 of colors, another gradient, a palette, a matplotlib colormap, or a string name of a
597 matplotlib colormap.
599 Examples
600 --------
602 Create a gradient from a list of colors:
604 .. testcode::
606 from chromo_map import ColorGradient
607 colors = ['#ff0000', '#00ff00', '#0000ff']
608 gradient = ColorGradient(colors, name='rGb)
609 gradient
611 .. html-output::
613 from chromo_map import ColorGradient
614 colors = ['#ff0000', '#00ff00', '#0000ff']
615 gradient = ColorGradient(colors, name='rGb')
616 print(gradient._repr_html_())
618 Or with hover information:
620 .. testcode::
622 gradient.to_div(as_png=False)
624 .. html-output::
626 print(gradient.to_div(as_png=False).data)
628 """
630 def _update_from_list(self, colors, name, alpha):
631 if not list(colors):
632 raise ValueError("No valid colors found.")
633 self.colors = tuple(Color(clr, alpha) for clr in colors)
634 mpl_colormap = LSC.from_list(name=name, colors=self.tup, N=len(self.colors))
635 self.__dict__.update(mpl_colormap.__dict__)
637 def with_alpha(self, alpha, name=None):
638 """Create a new gradient with a new alpha value.
640 Parameters
641 ----------
642 alpha : float
643 The new alpha value between 0 and 1.
645 name : str, optional
646 The name of the new gradient.
648 Returns
649 -------
650 ColorGradient
651 The new gradient with the new alpha value.
653 Examples
654 --------
656 Create a gradient with a new alpha value:
658 .. testcode::
660 from chromo_map import ColorGradient
661 colors = ['#ff0000', '#00ff00', '#0000ff']
662 gradient = ColorGradient(colors, name='rGb')
663 gradient.with_alpha(0.5)
665 .. html-output::
667 from chromo_map import ColorGradient
668 colors = ['#ff0000', '#00ff00', '#0000ff']
669 gradient = ColorGradient(colors, name='rGb')
670 print(gradient.with_alpha(0.5)._repr_html_())
672 """
673 return ColorGradient(
674 [Color(clr, alpha) for clr in self.colors], name=name or self.name
675 )
677 def __init__(self, colors, name=None, alpha=None):
678 name = name or "custom" if not hasattr(colors, "name") else colors.name
680 if isinstance(colors, (list, tuple, np.ndarray)):
681 self._update_from_list(colors, name, alpha)
683 elif isinstance(colors, ColorGradient):
684 self._update_from_list(colors.colors, name, alpha)
686 elif isinstance(colors, Palette):
687 self._update_from_list(colors.mpl_colors, name, alpha)
689 elif isinstance(colors, LSC):
690 self._update_from_list(colors(np.arange(colors.N)), name, alpha)
692 elif isinstance(colors, LC):
693 self._update_from_list(colors.colors, name, alpha)
695 elif isinstance(colors, str) and colors in _VALID_MPL_COLORS:
696 cmap = plt.get_cmap(colors)
697 self._update_from_list(cmap(np.arange(cmap.N)), name, alpha)
699 else:
700 cmap = LSC(name, colors)
701 self._update_from_list(cmap(np.arange(cmap.N)), name, alpha)
703 def __getattr__(self, name):
704 pass_through = (
705 "tup",
706 "hex",
707 "hexa",
708 "rgb",
709 "rgba",
710 "hextup",
711 "rgbtup",
712 "hexatup",
713 "rgbatup",
714 "r",
715 "g",
716 "b",
717 "a",
718 )
719 if name in pass_through:
720 return [getattr(clr, name) for clr in self.colors]
721 raise AttributeError(f"'ColorGradient' object has no attribute '{name}'")
723 def __getitem__(self, key):
724 if isinstance(key, slice):
725 start = key.start or 0
726 stop = key.stop or 1
727 num = key.step or len(self.colors)
728 return self[np.linspace(start, stop, num)]
729 if isinstance(key, int) and 0 <= key < len(self):
730 return self.colors[key]
731 if isinstance(key, float) and 0 <= key <= 1:
732 if key == 0:
733 return self.colors[0]
734 if key == 1:
735 return self.colors[-1]
737 x, i = np.modf(key * (self.N - 1))
738 i = int(i)
739 j = i + 1
740 c0 = self.colors[i]
741 c1 = self.colors[j]
742 return c0.interpolate(c1, x)
743 if isinstance(key, (list, tuple, np.ndarray)):
744 return ColorGradient([self[x] for x in key])
745 raise IndexError(f"Invalid index: {key}")
747 def __iter__(self):
748 return iter(self.colors)
750 def reversed(self, name=None):
751 """Return a new gradient with the colors reversed.
753 Parameters
754 ----------
755 name : str, optional
756 The name of the new gradient.
758 Returns
759 -------
760 ColorGradient
761 The new gradient with the colors reversed.
763 Examples
764 --------
766 Create a reversed gradient:
768 .. testcode::
770 from chromo_map import ColorGradient
771 colors = ['#ff0000', '#00ff00', '#0000ff']
772 gradient = ColorGradient(colors, name='rGb')
773 gradient.reversed()
775 .. html-output::
777 from chromo_map import ColorGradient
778 colors = ['#ff0000', '#00ff00', '#0000ff']
779 gradient = ColorGradient(colors, name='rGb')
780 print(gradient.reversed()._repr_html_())
782 """
783 if name is None:
784 name = f"{self.name}_r"
785 return ColorGradient(super().reversed(name=name))
787 @property
788 def _r(self):
789 return self.reversed()
791 def __len__(self):
792 return len(self.colors)
794 def resize(self, num):
795 """Resize the gradient to a new number of colors.
797 Parameters
798 ----------
799 num : int
800 The new number of colors.
802 Returns
803 -------
804 ColorGradient
805 The new gradient with the new number of colors.
807 Examples
808 --------
810 Resize the gradient to 32 colors:
812 .. testcode::
814 from chromo_map import ColorGradient
815 colors = ['#ff0000', '#00ff00', '#0000ff']
816 gradient = ColorGradient(colors, name='rGb')
817 gradient.resize(32)
819 .. html-output::
821 from chromo_map import ColorGradient
822 colors = ['#ff0000', '#00ff00', '#0000ff']
823 gradient = ColorGradient(colors, name='rGb')
824 print(gradient.resize(32)._repr_html_())
826 """
827 return ColorGradient(self.resampled(num), name=self.name)
829 def to_div(self, maxn=None, as_png=False):
830 """Convert the gradient to an HTML div.
832 Parameters
833 ----------
834 maxn : int, optional
835 The maximum number of colors to display.
837 as_png : bool, optional
838 Whether to display the gradient as a PNG image.
840 Returns
841 -------
842 HTML
843 The gradient as an HTML div.
845 Examples
846 --------
848 Convert the gradient to an HTML div:
850 .. testcode::
852 from chromo_map import ColorGradient
853 colors = ['#ff0000', '#00ff00', '#0000ff']
854 gradient = ColorGradient(colors, name='rGb')
855 gradient.to_div(as_png=False)
857 .. html-output::
859 from chromo_map import ColorGradient
860 colors = ['#ff0000', '#00ff00', '#0000ff']
861 gradient = ColorGradient(colors, name='rGb')
862 print(gradient.to_div(as_png=False).data)
864 """
865 max_flex_width = 500 / 16
866 n = len(self.colors)
867 if n == 0:
868 return ""
870 if maxn is not None and n > maxn:
871 cmap = self.resize(maxn)
872 else:
873 cmap = self
875 template = Template(
876 dedent(
877 """\
878 <div class="gradient">
879 <style>
880 #_{{ random_id }} {
881 display: flex; gap: 0rem; width: {{ max_width }}rem;
882 }
883 #_{{ random_id }} div { flex: 1 1 0; }
884 #_{{ random_id }} div.color { width: 100%; height: 100%; }
885 #_{{ random_id }} div.cmap { width: 100%; height: auto; }
886 #_{{ random_id }} div.cmap > img { width: 100%; height: 100%; }
887 </style>
888 <strong>{{ name }}</strong>
889 {% if as_png %}
890 {{ colors.to_png().data }}
891 {% else %}
892 <div id="_{{ random_id }}" class="color-map">
893 {% for clr in colors.colors %}
894 {{ clr._repr_html_() }}
895 {% endfor %}
896 </div>
897 {% endif %}
898 </div>
899 """
900 )
901 )
902 random_id = uuid.uuid4().hex
903 return HTML(
904 template.render(
905 name=cmap.name,
906 colors=cmap,
907 random_id=random_id,
908 max_width=max_flex_width,
909 as_png=as_png,
910 )
911 )
913 def to_matplotlib(self):
914 """Convert the gradient to a matplotlib figure."""
915 gradient = np.linspace(0, 1, self.N)
916 gradient = np.vstack((gradient, gradient))
918 _, ax = plt.subplots(figsize=(5, 0.5))
919 plt.subplots_adjust(left=0, right=1, top=1, bottom=0)
920 ax.set_position([0, 0, 1, 1])
921 ax.margins(0)
922 ax.imshow(gradient, aspect="auto", cmap=self)
923 ax.set_title(self.name)
924 ax.axis("off")
925 plt.show()
927 def to_drawing(self, width=500, height=50, filename=None):
928 """Convert the gradient to an SVG drawing."""
929 dwg = svgwrite.Drawing(filename, profile="tiny", size=(width, height))
930 rect_width = width / self.N
932 left = 0
933 for i, color in enumerate(self, 1):
934 right = int(i * rect_width)
935 actual_width = right - left + 1
936 dwg.add(
937 dwg.rect(
938 insert=(left, 0),
939 size=(actual_width, height),
940 fill=color.hex,
941 fill_opacity=color.a,
942 )
943 )
944 left = right
946 return dwg
948 def to_png(self):
949 """Convert the gradient to a PNG image."""
950 png_bytes = self._repr_png_()
951 png_base64 = base64.b64encode(png_bytes).decode("ascii")
952 div = f'<div class="cmap"><img src="data:image/png;base64,{png_base64}"></div>'
953 return HTML(div)
955 def _repr_html_(self, skip_super=False):
956 if hasattr(super(), "_repr_html_") and not skip_super:
957 return BeautifulSoup(super()._repr_html_(), "html.parser").prettify()
958 return self.to_div().data
960 def __add__(self, other):
961 name = f"{self.name} + {other.name}"
962 return ColorGradient(self.colors + other.colors, name=name)
964 def __mul__(self, other):
965 if isinstance(other, int):
966 return ColorGradient(self.colors * other, name=self.name)
967 raise ValueError("Invalid multiplication.")
969 def __rmul__(self, other):
970 return self.__mul__(other)
972 def __truediv__(self, other):
973 if isinstance(other, (int, float)):
974 return ColorGradient(self[:: other * len(self)], name=self.name)
975 raise ValueError("Invalid division.")
977 def __or__(self, other):
978 n = lcm(len(self), len(other))
979 a = self.resize(n)
980 b = other.resize(n)
981 name = f"{self.name} | {other.name}"
982 return ColorGradient([x | y for x, y in zip(a, b)], name=name)
984 def __eq__(self, other):
985 return np.isclose(self.tup, other.tup).all()
988class Swatch:
989 """A class for representing a collection of color gradients."""
991 def __init__(self, maps, maxn=32):
992 self.maxn = maxn
993 self.maps = []
994 for name, colors in maps.items():
995 try:
996 self.maps.append(ColorGradient(colors, name=name))
997 except ValueError as e:
998 raise e
999 self._repr_html_ = self.to_grid
1001 def to_dict(self):
1002 return {map.name: map.colors for map in self.maps}
1004 def __iter__(self):
1005 return iter(self.maps)
1007 def __len__(self):
1008 return len(self.maps)
1010 def with_max(self, maxn):
1011 return Swatch(self.to_dict(), maxn=maxn)
1013 def to_grid(self, as_png=False):
1014 """Convert the swatch to an HTML grid."""
1015 n = len(self.maps)
1016 if n == 0:
1017 return ""
1018 template = Template(
1019 dedent(
1020 """\
1021 <div id="_{{ random_id }}" class="color-swatch">
1022 <style>
1023 #_{{ random_id }} {
1024 display: grid;
1025 grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
1026 gap: 0.5rem 1rem;
1027 justify-content: space-between;
1028 overflow: hidden;
1029 resize: both;
1030 width: min(65rem, 100%);
1031 }
1032 #_{{ random_id }} div {
1033 width: 100%;
1034 }
1035 #_{{ random_id }} > div.gradient {
1036 width: 100%;
1037 height: min(4rem, 100%);
1038 display: grid;
1039 gap: 0.2rem;
1040 grid-template-rows: 1rem auto;
1041 }
1042 #_{{ random_id }} .color {
1043 height: minmax(1.5rem, 100%);
1044 }
1045 #_{{ random_id }} > div.gradient > strong {
1046 margin: 0;
1047 padding: 0;
1048 }
1049 #_{{ random_id }} img {height: 100%;}
1050 </style>
1051 {% for cmap in maps %}
1052 {{ cmap.to_div(maxn, as_png=as_png).data }}
1053 {% endfor %}
1054 </div>
1055 """
1056 )
1057 )
1058 random_id = uuid.uuid4().hex
1059 return HTML(
1060 template.render(
1061 maps=self.maps, random_id=random_id, maxn=self.maxn, as_png=as_png
1062 )
1063 )
1066def _gud_name(name):
1067 return not (name[0] == "_" or name[-2:] == "_r")
1070class ColorMaps(AttrDict):
1071 """A class for collecting color gradients.
1073 This class is a dictionary of color gradients. You can access the gradients by
1074 their names or by their categories.
1076 Examples
1077 --------
1079 Access a color gradient by name:
1081 .. testcode::
1083 from chromo_map import cmaps
1084 cmaps.plotly.carto.Vivid
1086 .. html-output::
1088 from chromo_map import cmaps
1089 print(cmaps.plotly.carto.Vivid._repr_html_())
1091 Access a color gradient by category:
1093 .. testcode::
1095 from chromo_map import cmaps
1096 cmaps.plotly.carto
1098 .. html-output::
1100 from chromo_map import cmaps
1101 print(cmaps.plotly.carto._repr_html_())
1103 """
1105 def __getattr__(self, item):
1106 if item in self:
1107 value = super().__getattr__(item)
1108 if not isinstance(value, type(self)):
1109 cmap = self._convert(value, item)
1110 if cmap.N > 32:
1111 cmap = cmap.resize(32)
1112 return cmap
1113 return value
1114 temp = type(self)({k: v for k, v in self.items() if k.startswith(item)})
1115 if temp:
1116 return temp
1117 raise AttributeError(
1118 f"'{type(self).__name__}' object has no attribute '{item}'"
1119 )
1121 @property
1122 def maps(self):
1123 return type(self)({k: v for k, v in self.items() if self._valid(v)})
1125 @property
1126 def _swatch(self):
1127 return Swatch(self.maps)
1129 def _repr_html_(self):
1130 return self._swatch.to_grid(as_png=True).data
1132 def to_grid(self, *args, **kwargs):
1133 return self._swatch.to_grid(*args, **kwargs)
1136class PlotlyColorMaps(ColorMaps):
1138 def _valid(self, value):
1139 return isinstance(value, list)
1141 def _convert(self, value, name):
1142 return ColorGradient(value, name=name)
1145class PalettableColorMaps(ColorMaps):
1147 def _valid(self, value):
1148 return isinstance(value, Palette)
1150 def _convert(self, value, name):
1151 return ColorGradient(value.mpl_colors, name=name)
1154class MPLColorMaps(ColorMaps):
1156 def _valid(self, value):
1157 return value in _VALID_MPL_COLORS
1159 def _convert(self, value, name):
1160 return ColorGradient(value, name=name)
1163plotly_cmaps = find_instances(
1164 cls=list,
1165 module=plotly_colors,
1166 tracker_type=PlotlyColorMaps,
1167 filter_func=lambda name, _: _gud_name(name),
1168)
1170palettable_cmaps = find_instances(
1171 cls=Palette,
1172 module=palettable,
1173 tracker_type=PalettableColorMaps,
1174 filter_func=lambda name, _: _gud_name(name),
1175)
1177mpl_dat = json.loads(
1178 files("chromo_map.data").joinpath("mpl_cat_names.json").read_text()
1179)
1181mpl_cmaps = MPLColorMaps(
1182 {cat: {name: name for name in names} for cat, names in mpl_dat}
1183)
1186cmaps = AttrDict()
1187cmaps["plotly"] = plotly_cmaps
1188cmaps["palettable"] = palettable_cmaps
1189cmaps["mpl"] = mpl_cmaps