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

1"""Color module for chromo_map package.""" 

2 

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 

24 

25 

26def _rgb_c(c): 

27 return rf"(?P<{c}>[^,\s]+)" 

28 

29 

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

37 

38_VALID_MPL_COLORS = plt.colormaps() 

39 

40 

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 

57 

58 

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 

65 

66 

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 

77 

78 

79class Color: 

80 """A class for representing colors. 

81 

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. 

86 

87 You can also pass in another Color object to create a copy of it with an optional 

88 new alpha value. 

89 

90 The class has properties that return the color in various formats including tuples, 

91 hex strings, RGB strings, and RGBA strings. 

92 

93 You can also interpolate between two colors by passing in another color and a factor 

94 between 0 and 1. 

95 

96 

97 Examples 

98 -------- 

99 

100 Simple red color: 

101 

102 .. testcode:: 

103 

104 from chromo_map import Color 

105 Color('red') 

106 

107 .. html-output:: 

108 

109 from chromo_map import Color 

110 print(Color('red')._repr_html_()) 

111 

112 Simple red color with alpha: 

113 

114 .. testcode:: 

115 

116 from chromo_map import Color 

117 Color('red', 0.5) 

118 

119 .. html-output:: 

120 

121 from chromo_map import Color 

122 print(Color('red', 0.5)._repr_html_()) 

123 

124 Green with hex string: 

125 

126 .. testcode:: 

127 

128 from chromo_map import Color 

129 Color('#007f00') 

130 

131 .. html-output:: 

132 

133 from chromo_map import Color 

134 print(Color('#007f00')._repr_html_()) 

135 

136 Blue with RGB string: 

137 

138 .. testcode:: 

139 

140 from chromo_map import Color 

141 Color('rgb(0, 0, 255)') 

142 

143 .. html-output:: 

144 

145 from chromo_map import Color 

146 print(Color('rgb(0, 0, 255)')._repr_html_()) 

147 

148 Blue with RGBA string and overidden alpha: 

149 

150 .. testcode:: 

151 

152 from chromo_map import Color 

153 Color('rgba(0, 0, 255, 0.5)', 0.75) 

154 

155 .. html-output:: 

156 

157 from chromo_map import Color 

158 print(Color('rgba(0, 0, 255, 0.5)', 0.75)._repr_html_()) 

159 

160 

161 """ 

162 

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 

177 

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 

184 

185 else: 

186 raise ValueError(f"Invalid color input '{type(clr).__name__}'.") 

187 

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.") 

195 

196 @property 

197 def tup(self): 

198 """Return the color as a tuple. 

199 

200 Returns 

201 ------- 

202 Tuple[float, float, float, float] 

203 The color as a tuple of RGBA values between 0 and 1. 

204 

205 Examples 

206 -------- 

207 

208 Get the color as a tuple: 

209 

210 .. testcode:: 

211 

212 from chromo_map import Color 

213 orange = Color('orange', 0.5) 

214 orange.tup 

215 

216 .. testoutput:: 

217 

218 (1.0, 0.6470588235294118, 0.0, 0.5) 

219 

220 """ 

221 return self.r, self.g, self.b, self.a 

222 

223 @property 

224 def hexatup(self): 

225 """Return the color as a tuple of hex values. 

226 

227 Returns 

228 ------- 

229 Tuple[int, int, int, int] 

230 The color as a tuple of RGBA values between 0 and 255. 

231 

232 Examples 

233 -------- 

234 

235 Get the color as a tuple of hex values: 

236 

237 .. testcode:: 

238 

239 from chromo_map import Color 

240 orange = Color('orange', 0.5) 

241 orange.hexatup 

242 

243 .. testoutput:: 

244 

245 (255, 165, 0, 127) 

246 

247 """ 

248 return tuple(int(x * 255) for x in self.tup) 

249 

250 @property 

251 def hextup(self): 

252 """Return the color as a tuple of hex values. 

253 

254 Returns 

255 ------- 

256 Tuple[int, int, int] 

257 The color as a tuple of RGB values between 0 and 255. 

258 

259 Examples 

260 -------- 

261 

262 Get the color as a tuple of hex values: 

263 

264 .. testcode:: 

265 

266 from chromo_map import Color 

267 orange = Color('orange', 0.5) 

268 orange.hextup 

269 

270 .. testoutput:: 

271 

272 (255, 165, 0) 

273 

274 """ 

275 return self.hexatup[:3] 

276 

277 @property 

278 def rgbtup(self): 

279 """Return the color as a tuple of RGB values. 

280 

281 Returns 

282 ------- 

283 Tuple[int, int, int] 

284 The color as a tuple of RGB values between 0 and 255. 

285 

286 Examples 

287 -------- 

288 

289 Get the color as a tuple of hex values: 

290 

291 .. testcode:: 

292 

293 from chromo_map import Color 

294 orange = Color('orange', 0.5) 

295 orange.rgbtup 

296 

297 .. testoutput:: 

298 

299 (255, 165, 0) 

300 

301 """ 

302 return self.hextup 

303 

304 @property 

305 def rgbatup(self): 

306 """Return the color as a tuple of RGBA values. 

307 

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. 

313 

314 Examples 

315 -------- 

316 Get the color as a tuple of RGBA values: 

317 

318 .. testcode:: 

319 

320 from chromo_map import Color 

321 orange = Color('orange', 0.5) 

322 orange.rgbatup 

323 

324 .. testoutput:: 

325 

326 (255, 165, 0, 0.5) 

327 

328 """ 

329 return self.rgbtup + (self.a,) 

330 

331 @property 

332 def hex(self): 

333 """Return the color as a hex string. 

334 

335 Returns 

336 ------- 

337 str 

338 The color as a hex string. 

339 

340 Examples 

341 -------- 

342 

343 Get the color as a hex string: 

344 

345 .. testcode:: 

346 

347 from chromo_map import Color 

348 orange = Color('orange', 0.5) 

349 orange.hex 

350 

351 .. testoutput:: 

352 

353 '#ffa500' 

354 

355 """ 

356 r, g, b = self.hextup 

357 return f"#{r:02x}{g:02x}{b:02x}" 

358 

359 @property 

360 def hexa(self): 

361 """Return the color as a hex string with an alpha value. 

362 

363 Returns 

364 ------- 

365 str 

366 The color as a hex string with an alpha value. 

367 

368 Examples 

369 -------- 

370 

371 Get the color as a hex string with an alpha value: 

372 

373 .. testcode:: 

374 

375 from chromo_map import Color 

376 orange = Color('orange', 0.5) 

377 orange.hexa 

378 

379 .. testoutput:: 

380 

381 '#ffa50080' 

382 

383 """ 

384 r, g, b, a = self.hexatup 

385 return f"#{r:02x}{g:02x}{b:02x}{a:02x}" 

386 

387 @property 

388 def rgb(self): 

389 """Return the color as an RGB string. 

390 

391 Returns 

392 ------- 

393 str 

394 The color as an RGB string. 

395 

396 Examples 

397 -------- 

398 

399 Get the color as an RGB string: 

400 

401 .. testcode:: 

402 

403 from chromo_map import Color 

404 orange = Color('orange', 0.5) 

405 orange.rgb 

406 

407 .. testoutput:: 

408 

409 'rgb(255, 165, 0)' 

410 

411 """ 

412 r, g, b = self.rgbtup 

413 return f"rgb({r}, {g}, {b})" 

414 

415 @property 

416 def rgba(self): 

417 """Return the color as an RGBA string. 

418 

419 Returns 

420 ------- 

421 str 

422 The color as an RGBA string. 

423 

424 Examples 

425 -------- 

426 

427 Get the color as an RGBA string: 

428 

429 .. testcode:: 

430 

431 from chromo_map import Color 

432 orange = Color('orange', 0.5) 

433 orange.rgba 

434 

435 .. testoutput:: 

436 

437 'rgba(255, 165, 0, 0.5)' 

438 

439 """ 

440 r, g, b, a = self.rgbatup 

441 return f"rgba({r}, {g}, {b}, {a:.1f})" 

442 

443 def interpolate(self, other, factor): 

444 """Interpolate between two colors. 

445 

446 Parameters 

447 ---------- 

448 other : Color 

449 The other color to interpolate with. 

450 

451 factor : float 

452 The interpolation factor between 0 and 1. 

453 

454 Returns 

455 ------- 

456 Color 

457 The interpolated color. 

458 

459 Examples 

460 -------- 

461 Interpolate between red and blue: 

462 

463 .. testcode:: 

464 

465 from chromo_map import Color 

466 red = Color('red') 

467 blue = Color('blue') 

468 

469 red.interpolate(blue, 0.5) 

470 

471 .. html-output:: 

472 

473 from chromo_map import Color 

474 red = Color('red') 

475 blue = Color('blue') 

476 print(red.interpolate(blue, 0.5)._repr_html_()) 

477 

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

484 

485 def __or__(self, other): 

486 """Interpolate between two colors assuming a factor of 0.5. 

487 

488 Parameters 

489 ---------- 

490 other : Color 

491 The other color to interpolate with. 

492 

493 Returns 

494 ------- 

495 Color 

496 The interpolated color. 

497 

498 Examples 

499 -------- 

500 

501 Interpolate between red and blue: 

502 

503 .. testcode:: 

504 

505 from chromo_map import Color 

506 red = Color('red') 

507 blue = Color('blue') 

508 red | blue 

509 

510 .. html-output:: 

511 

512 from chromo_map import Color 

513 red = Color('red') 

514 blue = Color('blue') 

515 print((red | blue)._repr_html_()) 

516 

517 """ 

518 return self.interpolate(other, 0.5) 

519 

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 ) 

572 

573 def __eq__(self, other): 

574 """Check if two colors are equal. 

575 

576 Only checks the result of the tuple property. 

577 

578 Parameters 

579 ---------- 

580 other : Color 

581 The other color to compare to. 

582 

583 Returns 

584 ------- 

585 bool 

586 Whether the colors are equal. 

587 """ 

588 return np.isclose(self.tup, other.tup).all() 

589 

590 

591class ColorGradient(LSC): 

592 """Mimics a matplotlib colormap with a list of colors. 

593 

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. 

598 

599 Examples 

600 -------- 

601 

602 Create a gradient from a list of colors: 

603 

604 .. testcode:: 

605 

606 from chromo_map import ColorGradient 

607 colors = ['#ff0000', '#00ff00', '#0000ff'] 

608 gradient = ColorGradient(colors, name='rGb) 

609 gradient 

610 

611 .. html-output:: 

612 

613 from chromo_map import ColorGradient 

614 colors = ['#ff0000', '#00ff00', '#0000ff'] 

615 gradient = ColorGradient(colors, name='rGb') 

616 print(gradient._repr_html_()) 

617 

618 Or with hover information: 

619 

620 .. testcode:: 

621 

622 gradient.to_div(as_png=False) 

623 

624 .. html-output:: 

625 

626 print(gradient.to_div(as_png=False).data) 

627 

628 """ 

629 

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

636 

637 def with_alpha(self, alpha, name=None): 

638 """Create a new gradient with a new alpha value. 

639 

640 Parameters 

641 ---------- 

642 alpha : float 

643 The new alpha value between 0 and 1. 

644 

645 name : str, optional 

646 The name of the new gradient. 

647 

648 Returns 

649 ------- 

650 ColorGradient 

651 The new gradient with the new alpha value. 

652 

653 Examples 

654 -------- 

655 

656 Create a gradient with a new alpha value: 

657 

658 .. testcode:: 

659 

660 from chromo_map import ColorGradient 

661 colors = ['#ff0000', '#00ff00', '#0000ff'] 

662 gradient = ColorGradient(colors, name='rGb') 

663 gradient.with_alpha(0.5) 

664 

665 .. html-output:: 

666 

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_()) 

671 

672 """ 

673 return ColorGradient( 

674 [Color(clr, alpha) for clr in self.colors], name=name or self.name 

675 ) 

676 

677 def __init__(self, colors, name=None, alpha=None): 

678 name = name or "custom" if not hasattr(colors, "name") else colors.name 

679 

680 if isinstance(colors, (list, tuple, np.ndarray)): 

681 self._update_from_list(colors, name, alpha) 

682 

683 elif isinstance(colors, ColorGradient): 

684 self._update_from_list(colors.colors, name, alpha) 

685 

686 elif isinstance(colors, Palette): 

687 self._update_from_list(colors.mpl_colors, name, alpha) 

688 

689 elif isinstance(colors, LSC): 

690 self._update_from_list(colors(np.arange(colors.N)), name, alpha) 

691 

692 elif isinstance(colors, LC): 

693 self._update_from_list(colors.colors, name, alpha) 

694 

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) 

698 

699 else: 

700 cmap = LSC(name, colors) 

701 self._update_from_list(cmap(np.arange(cmap.N)), name, alpha) 

702 

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

722 

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] 

736 

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

746 

747 def __iter__(self): 

748 return iter(self.colors) 

749 

750 def reversed(self, name=None): 

751 """Return a new gradient with the colors reversed. 

752 

753 Parameters 

754 ---------- 

755 name : str, optional 

756 The name of the new gradient. 

757 

758 Returns 

759 ------- 

760 ColorGradient 

761 The new gradient with the colors reversed. 

762 

763 Examples 

764 -------- 

765 

766 Create a reversed gradient: 

767 

768 .. testcode:: 

769 

770 from chromo_map import ColorGradient 

771 colors = ['#ff0000', '#00ff00', '#0000ff'] 

772 gradient = ColorGradient(colors, name='rGb') 

773 gradient.reversed() 

774 

775 .. html-output:: 

776 

777 from chromo_map import ColorGradient 

778 colors = ['#ff0000', '#00ff00', '#0000ff'] 

779 gradient = ColorGradient(colors, name='rGb') 

780 print(gradient.reversed()._repr_html_()) 

781 

782 """ 

783 if name is None: 

784 name = f"{self.name}_r" 

785 return ColorGradient(super().reversed(name=name)) 

786 

787 @property 

788 def _r(self): 

789 return self.reversed() 

790 

791 def __len__(self): 

792 return len(self.colors) 

793 

794 def resize(self, num): 

795 """Resize the gradient to a new number of colors. 

796 

797 Parameters 

798 ---------- 

799 num : int 

800 The new number of colors. 

801 

802 Returns 

803 ------- 

804 ColorGradient 

805 The new gradient with the new number of colors. 

806 

807 Examples 

808 -------- 

809 

810 Resize the gradient to 32 colors: 

811 

812 .. testcode:: 

813 

814 from chromo_map import ColorGradient 

815 colors = ['#ff0000', '#00ff00', '#0000ff'] 

816 gradient = ColorGradient(colors, name='rGb') 

817 gradient.resize(32) 

818 

819 .. html-output:: 

820 

821 from chromo_map import ColorGradient 

822 colors = ['#ff0000', '#00ff00', '#0000ff'] 

823 gradient = ColorGradient(colors, name='rGb') 

824 print(gradient.resize(32)._repr_html_()) 

825 

826 """ 

827 return ColorGradient(self.resampled(num), name=self.name) 

828 

829 def to_div(self, maxn=None, as_png=False): 

830 """Convert the gradient to an HTML div. 

831 

832 Parameters 

833 ---------- 

834 maxn : int, optional 

835 The maximum number of colors to display. 

836 

837 as_png : bool, optional 

838 Whether to display the gradient as a PNG image. 

839 

840 Returns 

841 ------- 

842 HTML 

843 The gradient as an HTML div. 

844 

845 Examples 

846 -------- 

847 

848 Convert the gradient to an HTML div: 

849 

850 .. testcode:: 

851 

852 from chromo_map import ColorGradient 

853 colors = ['#ff0000', '#00ff00', '#0000ff'] 

854 gradient = ColorGradient(colors, name='rGb') 

855 gradient.to_div(as_png=False) 

856 

857 .. html-output:: 

858 

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) 

863 

864 """ 

865 max_flex_width = 500 / 16 

866 n = len(self.colors) 

867 if n == 0: 

868 return "" 

869 

870 if maxn is not None and n > maxn: 

871 cmap = self.resize(maxn) 

872 else: 

873 cmap = self 

874 

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 ) 

912 

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

917 

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

926 

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 

931 

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 

945 

946 return dwg 

947 

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) 

954 

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 

959 

960 def __add__(self, other): 

961 name = f"{self.name} + {other.name}" 

962 return ColorGradient(self.colors + other.colors, name=name) 

963 

964 def __mul__(self, other): 

965 if isinstance(other, int): 

966 return ColorGradient(self.colors * other, name=self.name) 

967 raise ValueError("Invalid multiplication.") 

968 

969 def __rmul__(self, other): 

970 return self.__mul__(other) 

971 

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.") 

976 

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) 

983 

984 def __eq__(self, other): 

985 return np.isclose(self.tup, other.tup).all() 

986 

987 

988class Swatch: 

989 """A class for representing a collection of color gradients.""" 

990 

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 

1000 

1001 def to_dict(self): 

1002 return {map.name: map.colors for map in self.maps} 

1003 

1004 def __iter__(self): 

1005 return iter(self.maps) 

1006 

1007 def __len__(self): 

1008 return len(self.maps) 

1009 

1010 def with_max(self, maxn): 

1011 return Swatch(self.to_dict(), maxn=maxn) 

1012 

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 ) 

1064 

1065 

1066def _gud_name(name): 

1067 return not (name[0] == "_" or name[-2:] == "_r") 

1068 

1069 

1070class ColorMaps(AttrDict): 

1071 """A class for collecting color gradients. 

1072 

1073 This class is a dictionary of color gradients. You can access the gradients by 

1074 their names or by their categories. 

1075 

1076 Examples 

1077 -------- 

1078 

1079 Access a color gradient by name: 

1080 

1081 .. testcode:: 

1082 

1083 from chromo_map import cmaps 

1084 cmaps.plotly.carto.Vivid 

1085 

1086 .. html-output:: 

1087 

1088 from chromo_map import cmaps 

1089 print(cmaps.plotly.carto.Vivid._repr_html_()) 

1090 

1091 Access a color gradient by category: 

1092 

1093 .. testcode:: 

1094 

1095 from chromo_map import cmaps 

1096 cmaps.plotly.carto 

1097 

1098 .. html-output:: 

1099 

1100 from chromo_map import cmaps 

1101 print(cmaps.plotly.carto._repr_html_()) 

1102 

1103 """ 

1104 

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 ) 

1120 

1121 @property 

1122 def maps(self): 

1123 return type(self)({k: v for k, v in self.items() if self._valid(v)}) 

1124 

1125 @property 

1126 def _swatch(self): 

1127 return Swatch(self.maps) 

1128 

1129 def _repr_html_(self): 

1130 return self._swatch.to_grid(as_png=True).data 

1131 

1132 def to_grid(self, *args, **kwargs): 

1133 return self._swatch.to_grid(*args, **kwargs) 

1134 

1135 

1136class PlotlyColorMaps(ColorMaps): 

1137 

1138 def _valid(self, value): 

1139 return isinstance(value, list) 

1140 

1141 def _convert(self, value, name): 

1142 return ColorGradient(value, name=name) 

1143 

1144 

1145class PalettableColorMaps(ColorMaps): 

1146 

1147 def _valid(self, value): 

1148 return isinstance(value, Palette) 

1149 

1150 def _convert(self, value, name): 

1151 return ColorGradient(value.mpl_colors, name=name) 

1152 

1153 

1154class MPLColorMaps(ColorMaps): 

1155 

1156 def _valid(self, value): 

1157 return value in _VALID_MPL_COLORS 

1158 

1159 def _convert(self, value, name): 

1160 return ColorGradient(value, name=name) 

1161 

1162 

1163plotly_cmaps = find_instances( 

1164 cls=list, 

1165 module=plotly_colors, 

1166 tracker_type=PlotlyColorMaps, 

1167 filter_func=lambda name, _: _gud_name(name), 

1168) 

1169 

1170palettable_cmaps = find_instances( 

1171 cls=Palette, 

1172 module=palettable, 

1173 tracker_type=PalettableColorMaps, 

1174 filter_func=lambda name, _: _gud_name(name), 

1175) 

1176 

1177mpl_dat = json.loads( 

1178 files("chromo_map.data").joinpath("mpl_cat_names.json").read_text() 

1179) 

1180 

1181mpl_cmaps = MPLColorMaps( 

1182 {cat: {name: name for name in names} for cat, names in mpl_dat} 

1183) 

1184 

1185 

1186cmaps = AttrDict() 

1187cmaps["plotly"] = plotly_cmaps 

1188cmaps["palettable"] = palettable_cmaps 

1189cmaps["mpl"] = mpl_cmaps