ultraplot 0.99.3__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 (416) hide show
  1. ultraplot/__init__.py +115 -0
  2. ultraplot/__init__.py.rej +58 -0
  3. ultraplot/axes/__init__.py +42 -0
  4. ultraplot/axes/base.py +3240 -0
  5. ultraplot/axes/cartesian.py +1425 -0
  6. ultraplot/axes/geo.py +1675 -0
  7. ultraplot/axes/plot.py +4569 -0
  8. ultraplot/axes/polar.py +381 -0
  9. ultraplot/axes/shared.py +186 -0
  10. ultraplot/axes/three.py +34 -0
  11. ultraplot/cmaps/Algae.rgb +256 -0
  12. ultraplot/cmaps/Amp.rgb +256 -0
  13. ultraplot/cmaps/BR.rgb +256 -0
  14. ultraplot/cmaps/Balance.rgb +256 -0
  15. ultraplot/cmaps/Blues1_r.xml +17 -0
  16. ultraplot/cmaps/Blues2.xml +16 -0
  17. ultraplot/cmaps/Blues3.xml +25 -0
  18. ultraplot/cmaps/Blues4_r.xml +17 -0
  19. ultraplot/cmaps/Blues5.xml +16 -0
  20. ultraplot/cmaps/Blues6.xml +25 -0
  21. ultraplot/cmaps/Blues7.xml +16 -0
  22. ultraplot/cmaps/Blues8.xml +17 -0
  23. ultraplot/cmaps/Blues9.xml +1 -0
  24. ultraplot/cmaps/Boreal.json +53 -0
  25. ultraplot/cmaps/Browns1.xml +16 -0
  26. ultraplot/cmaps/Browns2.xml +26 -0
  27. ultraplot/cmaps/Browns3.xml +17 -0
  28. ultraplot/cmaps/Browns4.xml +17 -0
  29. ultraplot/cmaps/Browns5.xml +26 -0
  30. ultraplot/cmaps/Browns6.xml +17 -0
  31. ultraplot/cmaps/Browns7.xml +19 -0
  32. ultraplot/cmaps/Browns8.xml +11 -0
  33. ultraplot/cmaps/Browns9.xml +1 -0
  34. ultraplot/cmaps/ColdHot.rgb +229 -0
  35. ultraplot/cmaps/Crest.rgb +256 -0
  36. ultraplot/cmaps/Curl.rgb +512 -0
  37. ultraplot/cmaps/Deep.rgb +256 -0
  38. ultraplot/cmaps/Delta.rgb +512 -0
  39. ultraplot/cmaps/Dense.rgb +256 -0
  40. ultraplot/cmaps/Div.json +71 -0
  41. ultraplot/cmaps/DryWet.json +73 -0
  42. ultraplot/cmaps/Dusk.json +53 -0
  43. ultraplot/cmaps/Fire.json +53 -0
  44. ultraplot/cmaps/Flare.rgb +256 -0
  45. ultraplot/cmaps/Glacial.json +53 -0
  46. ultraplot/cmaps/Greens1_r.xml +26 -0
  47. ultraplot/cmaps/Greens2.xml +28 -0
  48. ultraplot/cmaps/Greens3_r.xml +28 -0
  49. ultraplot/cmaps/Greens4.xml +17 -0
  50. ultraplot/cmaps/Greens5.xml +16 -0
  51. ultraplot/cmaps/Greens6_r.xml +16 -0
  52. ultraplot/cmaps/Greens7.xml +16 -0
  53. ultraplot/cmaps/Greens8.xml +26 -0
  54. ultraplot/cmaps/Haline.rgb +256 -0
  55. ultraplot/cmaps/Ice.rgb +256 -0
  56. ultraplot/cmaps/IceFire.rgb +256 -0
  57. ultraplot/cmaps/Mako.rgb +256 -0
  58. ultraplot/cmaps/Marine.json +53 -0
  59. ultraplot/cmaps/Matter.rgb +256 -0
  60. ultraplot/cmaps/Mono.txt +256 -0
  61. ultraplot/cmaps/MonoCycle.txt +256 -0
  62. ultraplot/cmaps/NegPos.json +71 -0
  63. ultraplot/cmaps/Oranges1.xml +27 -0
  64. ultraplot/cmaps/Oranges2.xml +26 -0
  65. ultraplot/cmaps/Oranges3.xml +15 -0
  66. ultraplot/cmaps/Oranges4.xml +23 -0
  67. ultraplot/cmaps/Oxy.rgb +256 -0
  68. ultraplot/cmaps/Phase.rgb +256 -0
  69. ultraplot/cmaps/Purples1_r.xml +16 -0
  70. ultraplot/cmaps/Purples2.xml +17 -0
  71. ultraplot/cmaps/Purples3.xml +18 -0
  72. ultraplot/cmaps/Reds1.xml +26 -0
  73. ultraplot/cmaps/Reds2.xml +22 -0
  74. ultraplot/cmaps/Reds3.xml +23 -0
  75. ultraplot/cmaps/Reds4.xml +26 -0
  76. ultraplot/cmaps/Reds5.xml +17 -0
  77. ultraplot/cmaps/Rocket.rgb +256 -0
  78. ultraplot/cmaps/Solar.rgb +256 -0
  79. ultraplot/cmaps/Speed.rgb +256 -0
  80. ultraplot/cmaps/Stellar.json +53 -0
  81. ultraplot/cmaps/Sunrise.json +53 -0
  82. ultraplot/cmaps/Sunset.json +53 -0
  83. ultraplot/cmaps/Tempo.rgb +256 -0
  84. ultraplot/cmaps/Thermal.rgb +256 -0
  85. ultraplot/cmaps/Turbid.rgb +256 -0
  86. ultraplot/cmaps/Vivid.xml +11 -0
  87. ultraplot/cmaps/Vlag.rgb +256 -0
  88. ultraplot/cmaps/Yellows1.xml +17 -0
  89. ultraplot/cmaps/Yellows2.xml +17 -0
  90. ultraplot/cmaps/Yellows3.xml +17 -0
  91. ultraplot/cmaps/Yellows4.xml +17 -0
  92. ultraplot/cmaps/acton.txt +256 -0
  93. ultraplot/cmaps/bam.txt +256 -0
  94. ultraplot/cmaps/bamO.txt +256 -0
  95. ultraplot/cmaps/bamako.txt +256 -0
  96. ultraplot/cmaps/batlow.txt +256 -0
  97. ultraplot/cmaps/batlowK.txt +256 -0
  98. ultraplot/cmaps/batlowW.txt +256 -0
  99. ultraplot/cmaps/berlin.txt +256 -0
  100. ultraplot/cmaps/bilbao.txt +256 -0
  101. ultraplot/cmaps/broc.txt +256 -0
  102. ultraplot/cmaps/brocO.txt +256 -0
  103. ultraplot/cmaps/buda.txt +256 -0
  104. ultraplot/cmaps/bukavu.txt +256 -0
  105. ultraplot/cmaps/cork.txt +256 -0
  106. ultraplot/cmaps/corkO.txt +256 -0
  107. ultraplot/cmaps/davos.txt +256 -0
  108. ultraplot/cmaps/devon.txt +256 -0
  109. ultraplot/cmaps/fes.txt +256 -0
  110. ultraplot/cmaps/hawaii.txt +256 -0
  111. ultraplot/cmaps/imola.txt +256 -0
  112. ultraplot/cmaps/lajolla.txt +256 -0
  113. ultraplot/cmaps/lapaz.txt +256 -0
  114. ultraplot/cmaps/lisbon.txt +256 -0
  115. ultraplot/cmaps/nuuk.txt +256 -0
  116. ultraplot/cmaps/oleron.txt +256 -0
  117. ultraplot/cmaps/oslo.txt +256 -0
  118. ultraplot/cmaps/roma.txt +256 -0
  119. ultraplot/cmaps/romaO.txt +256 -0
  120. ultraplot/cmaps/tofino.txt +256 -0
  121. ultraplot/cmaps/tokyo.txt +256 -0
  122. ultraplot/cmaps/turku.txt +256 -0
  123. ultraplot/cmaps/vanimo.txt +256 -0
  124. ultraplot/cmaps/vik.txt +256 -0
  125. ultraplot/cmaps/vikO.txt +256 -0
  126. ultraplot/colors/opencolor.txt +132 -0
  127. ultraplot/colors/xkcd.txt +951 -0
  128. ultraplot/colors.py +3241 -0
  129. ultraplot/colors.py.rej +243 -0
  130. ultraplot/config.py +1809 -0
  131. ultraplot/constructor.py +1633 -0
  132. ultraplot/cycles/538.hex +2 -0
  133. ultraplot/cycles/FlatUI.hex +1 -0
  134. ultraplot/cycles/Qual1.rgb +7 -0
  135. ultraplot/cycles/Qual2.rgb +13 -0
  136. ultraplot/cycles/bmh.hex +2 -0
  137. ultraplot/cycles/classic.hex +2 -0
  138. ultraplot/cycles/colorblind.hex +2 -0
  139. ultraplot/cycles/colorblind10.hex +2 -0
  140. ultraplot/cycles/default.hex +2 -0
  141. ultraplot/cycles/ggplot.hex +1 -0
  142. ultraplot/cycles/seaborn.hex +2 -0
  143. ultraplot/cycles/tableau.hex +2 -0
  144. ultraplot/demos.py +1201 -0
  145. ultraplot/externals/__init__.py +5 -0
  146. ultraplot/externals/hsluv.py +330 -0
  147. ultraplot/figure.py +2102 -0
  148. ultraplot/fonts/FiraMath-Bold.ttf +0 -0
  149. ultraplot/fonts/FiraMath-ExtraLight.ttf +0 -0
  150. ultraplot/fonts/FiraMath-Heavy.ttf +0 -0
  151. ultraplot/fonts/FiraMath-Light.ttf +0 -0
  152. ultraplot/fonts/FiraMath-Medium.ttf +0 -0
  153. ultraplot/fonts/FiraMath-Regular.ttf +0 -0
  154. ultraplot/fonts/FiraMath-SemiBold.ttf +0 -0
  155. ultraplot/fonts/FiraMath-UltraLight.ttf +0 -0
  156. ultraplot/fonts/FiraSans-Black.ttf +0 -0
  157. ultraplot/fonts/FiraSans-BlackItalic.ttf +0 -0
  158. ultraplot/fonts/FiraSans-Bold.ttf +0 -0
  159. ultraplot/fonts/FiraSans-BoldItalic.ttf +0 -0
  160. ultraplot/fonts/FiraSans-ExtraBold.ttf +0 -0
  161. ultraplot/fonts/FiraSans-ExtraBoldItalic.ttf +0 -0
  162. ultraplot/fonts/FiraSans-ExtraLight.ttf +0 -0
  163. ultraplot/fonts/FiraSans-ExtraLightItalic.ttf +0 -0
  164. ultraplot/fonts/FiraSans-Italic.ttf +0 -0
  165. ultraplot/fonts/FiraSans-Light.ttf +0 -0
  166. ultraplot/fonts/FiraSans-LightItalic.ttf +0 -0
  167. ultraplot/fonts/FiraSans-Medium.ttf +0 -0
  168. ultraplot/fonts/FiraSans-MediumItalic.ttf +0 -0
  169. ultraplot/fonts/FiraSans-Regular.ttf +0 -0
  170. ultraplot/fonts/FiraSans-SemiBold.ttf +0 -0
  171. ultraplot/fonts/FiraSans-SemiBoldItalic.ttf +0 -0
  172. ultraplot/fonts/LICENSE_FIRAMATH.txt +92 -0
  173. ultraplot/fonts/LICENSE_FIRASANS.txt +97 -0
  174. ultraplot/fonts/LICENSE_NOTOSANS.txt +202 -0
  175. ultraplot/fonts/LICENSE_NOTOSERIF.txt +93 -0
  176. ultraplot/fonts/LICENSE_OPENSANS.txt +202 -0
  177. ultraplot/fonts/LICENSE_ROBOTO.txt +202 -0
  178. ultraplot/fonts/LICENSE_SOURCESANS.txt +93 -0
  179. ultraplot/fonts/LICENSE_SOURCESERIF.txt +93 -0
  180. ultraplot/fonts/LICENSE_TEXGYRE.txt +29 -0
  181. ultraplot/fonts/LICENSE_UBUNTU.txt +96 -0
  182. ultraplot/fonts/NotoSans-Bold.ttf +0 -0
  183. ultraplot/fonts/NotoSans-BoldItalic.ttf +0 -0
  184. ultraplot/fonts/NotoSans-Italic.ttf +0 -0
  185. ultraplot/fonts/NotoSans-Regular.ttf +0 -0
  186. ultraplot/fonts/NotoSerif-Bold.ttf +0 -0
  187. ultraplot/fonts/NotoSerif-BoldItalic.ttf +0 -0
  188. ultraplot/fonts/NotoSerif-Italic.ttf +0 -0
  189. ultraplot/fonts/NotoSerif-Regular.ttf +0 -0
  190. ultraplot/fonts/OpenSans-Bold.ttf +0 -0
  191. ultraplot/fonts/OpenSans-BoldItalic.ttf +0 -0
  192. ultraplot/fonts/OpenSans-Italic.ttf +0 -0
  193. ultraplot/fonts/OpenSans-Regular.ttf +0 -0
  194. ultraplot/fonts/OpenSans-Semibold.ttf +0 -0
  195. ultraplot/fonts/OpenSans-SemiboldItalic.ttf +0 -0
  196. ultraplot/fonts/Roboto-Black.ttf +0 -0
  197. ultraplot/fonts/Roboto-BlackItalic.ttf +0 -0
  198. ultraplot/fonts/Roboto-Bold.ttf +0 -0
  199. ultraplot/fonts/Roboto-BoldItalic.ttf +0 -0
  200. ultraplot/fonts/Roboto-Italic.ttf +0 -0
  201. ultraplot/fonts/Roboto-Light.ttf +0 -0
  202. ultraplot/fonts/Roboto-LightItalic.ttf +0 -0
  203. ultraplot/fonts/Roboto-Medium.ttf +0 -0
  204. ultraplot/fonts/Roboto-MediumItalic.ttf +0 -0
  205. ultraplot/fonts/Roboto-Regular.ttf +0 -0
  206. ultraplot/fonts/SourceSansPro-Black.ttf +0 -0
  207. ultraplot/fonts/SourceSansPro-BlackItalic.ttf +0 -0
  208. ultraplot/fonts/SourceSansPro-Bold.ttf +0 -0
  209. ultraplot/fonts/SourceSansPro-BoldItalic.ttf +0 -0
  210. ultraplot/fonts/SourceSansPro-ExtraLight.ttf +0 -0
  211. ultraplot/fonts/SourceSansPro-ExtraLightItalic.ttf +0 -0
  212. ultraplot/fonts/SourceSansPro-Italic.ttf +0 -0
  213. ultraplot/fonts/SourceSansPro-Light.ttf +0 -0
  214. ultraplot/fonts/SourceSansPro-LightItalic.ttf +0 -0
  215. ultraplot/fonts/SourceSansPro-Regular.ttf +0 -0
  216. ultraplot/fonts/SourceSansPro-SemiBold.ttf +0 -0
  217. ultraplot/fonts/SourceSansPro-SemiBoldItalic.ttf +0 -0
  218. ultraplot/fonts/SourceSerifPro-Black.ttf +0 -0
  219. ultraplot/fonts/SourceSerifPro-BlackItalic.ttf +0 -0
  220. ultraplot/fonts/SourceSerifPro-Bold.ttf +0 -0
  221. ultraplot/fonts/SourceSerifPro-BoldItalic.ttf +0 -0
  222. ultraplot/fonts/SourceSerifPro-ExtraLight.ttf +0 -0
  223. ultraplot/fonts/SourceSerifPro-ExtraLightItalic.ttf +0 -0
  224. ultraplot/fonts/SourceSerifPro-Italic.ttf +0 -0
  225. ultraplot/fonts/SourceSerifPro-Light.ttf +0 -0
  226. ultraplot/fonts/SourceSerifPro-LightItalic.ttf +0 -0
  227. ultraplot/fonts/SourceSerifPro-Regular.ttf +0 -0
  228. ultraplot/fonts/SourceSerifPro-SemiBold.ttf +0 -0
  229. ultraplot/fonts/SourceSerifPro-SemiBoldItalic.ttf +0 -0
  230. ultraplot/fonts/Ubuntu-Bold.ttf +0 -0
  231. ultraplot/fonts/Ubuntu-BoldItalic.ttf +0 -0
  232. ultraplot/fonts/Ubuntu-Italic.ttf +0 -0
  233. ultraplot/fonts/Ubuntu-Light.ttf +0 -0
  234. ultraplot/fonts/Ubuntu-LightItalic.ttf +0 -0
  235. ultraplot/fonts/Ubuntu-Medium.ttf +0 -0
  236. ultraplot/fonts/Ubuntu-MediumItalic.ttf +0 -0
  237. ultraplot/fonts/Ubuntu-Regular.ttf +0 -0
  238. ultraplot/fonts/texgyreadventor-bold.ttf +0 -0
  239. ultraplot/fonts/texgyreadventor-bolditalic.ttf +0 -0
  240. ultraplot/fonts/texgyreadventor-italic.ttf +0 -0
  241. ultraplot/fonts/texgyreadventor-regular.ttf +0 -0
  242. ultraplot/fonts/texgyrebonum-bold.ttf +0 -0
  243. ultraplot/fonts/texgyrebonum-bolditalic.ttf +0 -0
  244. ultraplot/fonts/texgyrebonum-italic.ttf +0 -0
  245. ultraplot/fonts/texgyrebonum-regular.ttf +0 -0
  246. ultraplot/fonts/texgyrechorus-mediumitalic.ttf +0 -0
  247. ultraplot/fonts/texgyrecursor-bold.ttf +0 -0
  248. ultraplot/fonts/texgyrecursor-bolditalic.ttf +0 -0
  249. ultraplot/fonts/texgyrecursor-italic.ttf +0 -0
  250. ultraplot/fonts/texgyrecursor-regular.ttf +0 -0
  251. ultraplot/fonts/texgyreheros-bold.ttf +0 -0
  252. ultraplot/fonts/texgyreheros-bolditalic.ttf +0 -0
  253. ultraplot/fonts/texgyreheros-italic.ttf +0 -0
  254. ultraplot/fonts/texgyreheros-regular.ttf +0 -0
  255. ultraplot/fonts/texgyrepagella-bold.ttf +0 -0
  256. ultraplot/fonts/texgyrepagella-bolditalic.ttf +0 -0
  257. ultraplot/fonts/texgyrepagella-italic.ttf +0 -0
  258. ultraplot/fonts/texgyrepagella-regular.ttf +0 -0
  259. ultraplot/fonts/texgyreschola-bold.ttf +0 -0
  260. ultraplot/fonts/texgyreschola-bolditalic.ttf +0 -0
  261. ultraplot/fonts/texgyreschola-italic.ttf +0 -0
  262. ultraplot/fonts/texgyreschola-regular.ttf +0 -0
  263. ultraplot/fonts/texgyretermes-bold.ttf +0 -0
  264. ultraplot/fonts/texgyretermes-bolditalic.ttf +0 -0
  265. ultraplot/fonts/texgyretermes-italic.ttf +0 -0
  266. ultraplot/fonts/texgyretermes-regular.ttf +0 -0
  267. ultraplot/gridspec.py +1698 -0
  268. ultraplot/internals/__init__.py +529 -0
  269. ultraplot/internals/benchmarks.py +26 -0
  270. ultraplot/internals/context.py +44 -0
  271. ultraplot/internals/docstring.py +139 -0
  272. ultraplot/internals/fonts.py +75 -0
  273. ultraplot/internals/guides.py +167 -0
  274. ultraplot/internals/inputs.py +862 -0
  275. ultraplot/internals/labels.py +85 -0
  276. ultraplot/internals/rcsetup.py +1933 -0
  277. ultraplot/internals/versions.py +61 -0
  278. ultraplot/internals/warnings.py +122 -0
  279. ultraplot/proj.py +325 -0
  280. ultraplot/scale.py +966 -0
  281. ultraplot/tests/__init__.py +28 -0
  282. ultraplot/tests/baseline/test_align_labels.png +0 -0
  283. ultraplot/tests/baseline/test_aligned_outer_guides.png +0 -0
  284. ultraplot/tests/baseline/test_aspect_ratios.png +0 -0
  285. ultraplot/tests/baseline/test_auto_diverging1.png +0 -0
  286. ultraplot/tests/baseline/test_auto_legend.png +0 -0
  287. ultraplot/tests/baseline/test_auto_reverse.png +0 -0
  288. ultraplot/tests/baseline/test_autodiverging3.png +0 -0
  289. ultraplot/tests/baseline/test_autodiverging4.png +0 -0
  290. ultraplot/tests/baseline/test_autodiverging5.png +0 -0
  291. ultraplot/tests/baseline/test_axes_colors.png +0 -0
  292. ultraplot/tests/baseline/test_bar_vectors.png +0 -0
  293. ultraplot/tests/baseline/test_bar_width.png +0 -0
  294. ultraplot/tests/baseline/test_both_ticklabels.png +0 -0
  295. ultraplot/tests/baseline/test_bounds_ticks.png +0 -0
  296. ultraplot/tests/baseline/test_boxplot_colors.png +0 -0
  297. ultraplot/tests/baseline/test_boxplot_vectors.png +0 -0
  298. ultraplot/tests/baseline/test_cartopy_contours.png +0 -0
  299. ultraplot/tests/baseline/test_cartopy_labels.png +0 -0
  300. ultraplot/tests/baseline/test_cartopy_manual.png +0 -0
  301. ultraplot/tests/baseline/test_centered_legends.png +0 -0
  302. ultraplot/tests/baseline/test_cmap_cycles.png +0 -0
  303. ultraplot/tests/baseline/test_colorbar.png +0 -0
  304. ultraplot/tests/baseline/test_colorbar_ticks.png +0 -0
  305. ultraplot/tests/baseline/test_colormap_mode.png +0 -0
  306. ultraplot/tests/baseline/test_column_iteration.png +0 -0
  307. ultraplot/tests/baseline/test_complex_ticks.png +0 -0
  308. ultraplot/tests/baseline/test_contour_labels.png +0 -0
  309. ultraplot/tests/baseline/test_contour_legend_with_label.png +0 -0
  310. ultraplot/tests/baseline/test_contour_legend_without_label.png +0 -0
  311. ultraplot/tests/baseline/test_contour_negative.png +0 -0
  312. ultraplot/tests/baseline/test_contour_single.png +0 -0
  313. ultraplot/tests/baseline/test_cutoff_ticks.png +0 -0
  314. ultraplot/tests/baseline/test_data_keyword.png +0 -0
  315. ultraplot/tests/baseline/test_discrete_ticks.png +0 -0
  316. ultraplot/tests/baseline/test_discrete_vs_fixed.png +0 -0
  317. ultraplot/tests/baseline/test_drawing_in_projection_with_globe.png +0 -0
  318. ultraplot/tests/baseline/test_drawing_in_projection_without_globe.png +0 -0
  319. ultraplot/tests/baseline/test_edge_fix.png +0 -0
  320. ultraplot/tests/baseline/test_flow_functions.png +0 -0
  321. ultraplot/tests/baseline/test_font_adjustments.png +0 -0
  322. ultraplot/tests/baseline/test_geographic_multiple_projections.png +0 -0
  323. ultraplot/tests/baseline/test_geographic_single_projection.png +0 -0
  324. ultraplot/tests/baseline/test_gray_adjustment.png +0 -0
  325. ultraplot/tests/baseline/test_histogram_legend.png +0 -0
  326. ultraplot/tests/baseline/test_histogram_types.png +0 -0
  327. ultraplot/tests/baseline/test_ignore_message.png +0 -0
  328. ultraplot/tests/baseline/test_inbounds_data.png +0 -0
  329. ultraplot/tests/baseline/test_init_format.png +0 -0
  330. ultraplot/tests/baseline/test_inner_title_zorder.png +0 -0
  331. ultraplot/tests/baseline/test_inset_basic.png +0 -0
  332. ultraplot/tests/baseline/test_inset_colorbars.png +0 -0
  333. ultraplot/tests/baseline/test_inset_colors_1.png +0 -0
  334. ultraplot/tests/baseline/test_inset_colors_2.png +0 -0
  335. ultraplot/tests/baseline/test_inset_zoom_update.png +0 -0
  336. ultraplot/tests/baseline/test_invalid_dist.png +0 -0
  337. ultraplot/tests/baseline/test_invalid_plot.png +0 -0
  338. ultraplot/tests/baseline/test_keep_guide_labels.png +0 -0
  339. ultraplot/tests/baseline/test_label_settings.png +0 -0
  340. ultraplot/tests/baseline/test_level_restriction.png +0 -0
  341. ultraplot/tests/baseline/test_levels_with_vmin_vmax.png +0 -0
  342. ultraplot/tests/baseline/test_locale_formatting.png +0 -0
  343. ultraplot/tests/baseline/test_locale_formatting_en_US.UTF-8.png +0 -0
  344. ultraplot/tests/baseline/test_manual_labels.png +0 -0
  345. ultraplot/tests/baseline/test_multi_formatting.png +0 -0
  346. ultraplot/tests/baseline/test_multiple_calls.png +0 -0
  347. ultraplot/tests/baseline/test_on_the_fly_mappable.png +0 -0
  348. ultraplot/tests/baseline/test_outer_align.png +0 -0
  349. ultraplot/tests/baseline/test_panel_dist.png +0 -0
  350. ultraplot/tests/baseline/test_panels_suplabels_three_hor_panels.png +0 -0
  351. ultraplot/tests/baseline/test_panels_with_sharing.png +0 -0
  352. ultraplot/tests/baseline/test_panels_without_sharing_1.png +0 -0
  353. ultraplot/tests/baseline/test_panels_without_sharing_2.png +0 -0
  354. ultraplot/tests/baseline/test_parametric_colors.png +0 -0
  355. ultraplot/tests/baseline/test_parametric_labels.png +0 -0
  356. ultraplot/tests/baseline/test_patch_format.png +0 -0
  357. ultraplot/tests/baseline/test_pie_charts.png +0 -0
  358. ultraplot/tests/baseline/test_pint_quantities.png +0 -0
  359. ultraplot/tests/baseline/test_polar_projections.png +0 -0
  360. ultraplot/tests/baseline/test_projection_dicts.png +0 -0
  361. ultraplot/tests/baseline/test_qualitative_colormaps_1.png +0 -0
  362. ultraplot/tests/baseline/test_qualitative_colormaps_2.png +0 -0
  363. ultraplot/tests/baseline/test_reversed_levels.png +0 -0
  364. ultraplot/tests/baseline/test_scatter_alpha.png +0 -0
  365. ultraplot/tests/baseline/test_scatter_args.png +0 -0
  366. ultraplot/tests/baseline/test_scatter_cycle.png +0 -0
  367. ultraplot/tests/baseline/test_scatter_inbounds.png +0 -0
  368. ultraplot/tests/baseline/test_scatter_sizes.png +0 -0
  369. ultraplot/tests/baseline/test_seaborn_heatmap.png +0 -0
  370. ultraplot/tests/baseline/test_seaborn_hist.png +0 -0
  371. ultraplot/tests/baseline/test_seaborn_relational.png +0 -0
  372. ultraplot/tests/baseline/test_seaborn_swarmplot.png +0 -0
  373. ultraplot/tests/baseline/test_segmented_norm.png +0 -0
  374. ultraplot/tests/baseline/test_segmented_norm_ticks.png +0 -0
  375. ultraplot/tests/baseline/test_share_all_basic.png +0 -0
  376. ultraplot/tests/baseline/test_singleton_legend.png +0 -0
  377. ultraplot/tests/baseline/test_span_labels.png +0 -0
  378. ultraplot/tests/baseline/test_spine_offset.png +0 -0
  379. ultraplot/tests/baseline/test_spine_side.png +0 -0
  380. ultraplot/tests/baseline/test_standardized_input.png +0 -0
  381. ultraplot/tests/baseline/test_statistical_boxplot.png +0 -0
  382. ultraplot/tests/baseline/test_three_axes.png +0 -0
  383. ultraplot/tests/baseline/test_tick_direction.png +0 -0
  384. ultraplot/tests/baseline/test_tick_labels.png +0 -0
  385. ultraplot/tests/baseline/test_tick_length.png +0 -0
  386. ultraplot/tests/baseline/test_tick_width.png +0 -0
  387. ultraplot/tests/baseline/test_title_deflection.png +0 -0
  388. ultraplot/tests/baseline/test_triangular_functions.png +0 -0
  389. ultraplot/tests/baseline/test_tuple_handles.png +0 -0
  390. ultraplot/tests/baseline/test_twin_axes_1.png +0 -0
  391. ultraplot/tests/baseline/test_twin_axes_2.png +0 -0
  392. ultraplot/tests/baseline/test_twin_axes_3.png +0 -0
  393. ultraplot/tests/baseline/test_uneven_levels.png +0 -0
  394. ultraplot/tests/test_1dplots.py +373 -0
  395. ultraplot/tests/test_2dplots.py +354 -0
  396. ultraplot/tests/test_axes.py +179 -0
  397. ultraplot/tests/test_colorbar.py +253 -0
  398. ultraplot/tests/test_docs.py +78 -0
  399. ultraplot/tests/test_format.py +340 -0
  400. ultraplot/tests/test_geographic.py +116 -0
  401. ultraplot/tests/test_imshow.py +110 -0
  402. ultraplot/tests/test_inset.py +28 -0
  403. ultraplot/tests/test_integration.py +149 -0
  404. ultraplot/tests/test_legend.py +181 -0
  405. ultraplot/tests/test_projections.py +138 -0
  406. ultraplot/tests/test_statistical_plotting.py +77 -0
  407. ultraplot/tests/test_subplots.py +174 -0
  408. ultraplot/ticker.py +879 -0
  409. ultraplot/ui.py +233 -0
  410. ultraplot/utils.py +912 -0
  411. ultraplot-0.99.3.dist-info/LICENSE.txt +427 -0
  412. ultraplot-0.99.3.dist-info/METADATA +88 -0
  413. ultraplot-0.99.3.dist-info/RECORD +416 -0
  414. ultraplot-0.99.3.dist-info/WHEEL +5 -0
  415. ultraplot-0.99.3.dist-info/entry_points.txt +2 -0
  416. ultraplot-0.99.3.dist-info/top_level.txt +1 -0
ultraplot/colors.py ADDED
@@ -0,0 +1,3241 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Various colormap classes and colormap normalization classes.
4
+ """
5
+ # NOTE: To avoid name conflicts between registered colormaps and colors, print
6
+ # set(pplt.colors._cmap_database) & set(pplt.colors._color_database) whenever
7
+ # you add new colormaps. v0.8 result is {'gray', 'marine', 'ocean', 'pink'} due
8
+ # to the MATLAB and GNUPlot colormaps. Want to minimize conflicts.
9
+ # NOTE: We feel that LinearSegmentedColormap should always be used for smooth color
10
+ # transitions while ListedColormap should always be used for qualitative color sets.
11
+ # Other sources use ListedColormap for dense "perceptually uniform" colormaps possibly
12
+ # seeking optimization. However testing reveals that initialization of even very
13
+ # dense 256-level colormaps is only 1.25ms vs. 0.25ms for a ListedColormap with the
14
+ # same data (+1ms). Also ListedColormap was designed for qualitative transitions
15
+ # because specifying N different from len(colors) will cyclically loop around the
16
+ # colors or truncate colors. So we translate the relevant ListedColormaps to
17
+ # LinearSegmentedColormaps for consistency. See :rc:`cmap.listedthresh`
18
+ import functools
19
+ import json
20
+ import os
21
+ import re
22
+ from collections.abc import MutableMapping
23
+ from numbers import Integral, Number
24
+ from xml.etree import ElementTree
25
+
26
+ import matplotlib.cm as mcm
27
+ import matplotlib as mpl
28
+ import matplotlib.colors as mcolors
29
+ import numpy as np
30
+ import numpy.ma as ma
31
+
32
+ from .config import rc
33
+ from .internals import ic # noqa: F401
34
+ from .internals import (
35
+ _kwargs_to_args,
36
+ _not_none,
37
+ _pop_props,
38
+ docstring,
39
+ inputs,
40
+ warnings,
41
+ )
42
+ from .utils import set_alpha, to_hex, to_rgb, to_rgba, to_xyz, to_xyza
43
+
44
+ __all__ = [
45
+ "DiscreteColormap",
46
+ "ContinuousColormap",
47
+ "PerceptualColormap",
48
+ "DiscreteNorm",
49
+ "DivergingNorm",
50
+ "SegmentedNorm",
51
+ "ColorDatabase",
52
+ "ColormapDatabase",
53
+ "ListedColormap", # deprecated
54
+ "LinearSegmentedColormap", # deprecated
55
+ "PerceptuallyUniformColormap", # deprecated
56
+ "LinearSegmentedNorm", # deprecated
57
+ ]
58
+
59
+ # Default colormap properties
60
+ DEFAULT_NAME = "_no_name"
61
+ DEFAULT_SPACE = "hsl"
62
+
63
+ # Color regexes
64
+ # NOTE: We do not compile hex regex because config.py needs this surrounded by \A\Z
65
+ _regex_hex = r"#(?:[0-9a-fA-F]{3,4}){2}" # 6-8 digit hex
66
+ REGEX_HEX_MULTI = re.compile(_regex_hex)
67
+ REGEX_HEX_SINGLE = re.compile(rf"\A{_regex_hex}\Z")
68
+ REGEX_ADJUST = re.compile(r"\A(light|dark|medium|pale|charcoal)?\s*(gr[ea]y[0-9]?)?\Z")
69
+
70
+ # Colormap constants
71
+ CMAPS_CYCLIC = tuple( # cyclic colormaps loaded from rgb files
72
+ key.lower()
73
+ for key in (
74
+ "MonoCycle",
75
+ "twilight",
76
+ "Phase",
77
+ "romaO",
78
+ "brocO",
79
+ "corkO",
80
+ "vikO",
81
+ "bamO",
82
+ )
83
+ )
84
+ CMAPS_DIVERGING = { # mirrored dictionary mapping for reversed names
85
+ key.lower(): value.lower()
86
+ for key1, key2 in (
87
+ ("BR", "RB"),
88
+ ("NegPos", "PosNeg"),
89
+ ("CoolWarm", "WarmCool"),
90
+ ("ColdHot", "HotCold"),
91
+ ("DryWet", "WetDry"),
92
+ ("PiYG", "GYPi"),
93
+ ("PRGn", "GnRP"),
94
+ ("BrBG", "GBBr"),
95
+ ("PuOr", "OrPu"),
96
+ ("RdGy", "GyRd"),
97
+ ("RdBu", "BuRd"),
98
+ ("RdYlBu", "BuYlRd"),
99
+ ("RdYlGn", "GnYlRd"),
100
+ )
101
+ for key, value in ((key1, key2), (key2, key1))
102
+ }
103
+ for _cmap_diverging in ( # remaining diverging cmaps (see PlotAxes._parse_cmap)
104
+ "Div",
105
+ "Vlag",
106
+ "Spectral",
107
+ "Balance",
108
+ "Delta",
109
+ "Curl",
110
+ "roma",
111
+ "broc",
112
+ "cork",
113
+ "vik",
114
+ "bam",
115
+ "lisbon",
116
+ "tofino",
117
+ "berlin",
118
+ "vanimo",
119
+ ):
120
+ CMAPS_DIVERGING[_cmap_diverging.lower()] = _cmap_diverging.lower()
121
+ CMAPS_REMOVED = {
122
+ "Blue0": "0.6.0",
123
+ "Cool": "0.6.0",
124
+ "Warm": "0.6.0",
125
+ "Hot": "0.6.0",
126
+ "Floral": "0.6.0",
127
+ "Contrast": "0.6.0",
128
+ "Sharp": "0.6.0",
129
+ "Viz": "0.6.0",
130
+ }
131
+ CMAPS_RENAMED = {
132
+ "GrayCycle": ("MonoCycle", "0.6.0"),
133
+ "Blue1": ("Blues1", "0.7.0"),
134
+ "Blue2": ("Blues2", "0.7.0"),
135
+ "Blue3": ("Blues3", "0.7.0"),
136
+ "Blue4": ("Blues4", "0.7.0"),
137
+ "Blue5": ("Blues5", "0.7.0"),
138
+ "Blue6": ("Blues6", "0.7.0"),
139
+ "Blue7": ("Blues7", "0.7.0"),
140
+ "Blue8": ("Blues8", "0.7.0"),
141
+ "Blue9": ("Blues9", "0.7.0"),
142
+ "Green1": ("Greens1", "0.7.0"),
143
+ "Green2": ("Greens2", "0.7.0"),
144
+ "Green3": ("Greens3", "0.7.0"),
145
+ "Green4": ("Greens4", "0.7.0"),
146
+ "Green5": ("Greens5", "0.7.0"),
147
+ "Green6": ("Greens6", "0.7.0"),
148
+ "Green7": ("Greens7", "0.7.0"),
149
+ "Green8": ("Greens8", "0.7.0"),
150
+ "Orange1": ("Yellows1", "0.7.0"),
151
+ "Orange2": ("Yellows2", "0.7.0"),
152
+ "Orange3": ("Yellows3", "0.7.0"),
153
+ "Orange4": ("Oranges2", "0.7.0"),
154
+ "Orange5": ("Oranges1", "0.7.0"),
155
+ "Orange6": ("Oranges3", "0.7.0"),
156
+ "Orange7": ("Oranges4", "0.7.0"),
157
+ "Orange8": ("Yellows4", "0.7.0"),
158
+ "Brown1": ("Browns1", "0.7.0"),
159
+ "Brown2": ("Browns2", "0.7.0"),
160
+ "Brown3": ("Browns3", "0.7.0"),
161
+ "Brown4": ("Browns4", "0.7.0"),
162
+ "Brown5": ("Browns5", "0.7.0"),
163
+ "Brown6": ("Browns6", "0.7.0"),
164
+ "Brown7": ("Browns7", "0.7.0"),
165
+ "Brown8": ("Browns8", "0.7.0"),
166
+ "Brown9": ("Browns9", "0.7.0"),
167
+ "RedPurple1": ("Reds1", "0.7.0"),
168
+ "RedPurple2": ("Reds2", "0.7.0"),
169
+ "RedPurple3": ("Reds3", "0.7.0"),
170
+ "RedPurple4": ("Reds4", "0.7.0"),
171
+ "RedPurple5": ("Reds5", "0.7.0"),
172
+ "RedPurple6": ("Purples1", "0.7.0"),
173
+ "RedPurple7": ("Purples2", "0.7.0"),
174
+ "RedPurple8": ("Purples3", "0.7.0"),
175
+ }
176
+
177
+ # Color constants
178
+ COLORS_OPEN = {} # populated during register_colors
179
+ COLORS_XKCD = {} # populated during register_colors
180
+ COLORS_KEEP = (
181
+ *( # always load these XKCD colors regardless of settings
182
+ "charcoal",
183
+ "tomato",
184
+ "burgundy",
185
+ "maroon",
186
+ "burgundy",
187
+ "lavendar",
188
+ "taupe",
189
+ "sand",
190
+ "stone",
191
+ "earth",
192
+ "sand brown",
193
+ "sienna",
194
+ "terracotta",
195
+ "moss",
196
+ "crimson",
197
+ "mauve",
198
+ "rose",
199
+ "teal",
200
+ "forest",
201
+ "grass",
202
+ "sage",
203
+ "pine",
204
+ "vermillion",
205
+ "russet",
206
+ "cerise",
207
+ "avocado",
208
+ "wine",
209
+ "brick",
210
+ "umber",
211
+ "mahogany",
212
+ "puce",
213
+ "grape",
214
+ "blurple",
215
+ "cranberry",
216
+ "sand",
217
+ "aqua",
218
+ "jade",
219
+ "coral",
220
+ "olive",
221
+ "magenta",
222
+ "turquoise",
223
+ "sea blue",
224
+ "royal blue",
225
+ "slate blue",
226
+ "slate grey",
227
+ "baby blue",
228
+ "salmon",
229
+ "beige",
230
+ "peach",
231
+ "mustard",
232
+ "lime",
233
+ "indigo",
234
+ "cornflower",
235
+ "marine",
236
+ "cloudy blue",
237
+ "tangerine",
238
+ "scarlet",
239
+ "navy",
240
+ "cool grey",
241
+ "warm grey",
242
+ "chocolate",
243
+ "raspberry",
244
+ "denim",
245
+ "gunmetal",
246
+ "midnight",
247
+ "chartreuse",
248
+ "ivory",
249
+ "khaki",
250
+ "plum",
251
+ "silver",
252
+ "tan",
253
+ "wheat",
254
+ "buff",
255
+ "bisque",
256
+ "cerulean",
257
+ ),
258
+ *( # common combinations
259
+ "red orange",
260
+ "yellow orange",
261
+ "yellow green",
262
+ "blue green",
263
+ "blue violet",
264
+ "red violet",
265
+ "bright red", # backwards compatibility
266
+ ),
267
+ *( # common names
268
+ prefix + color
269
+ for color in (
270
+ "red",
271
+ "orange",
272
+ "yellow",
273
+ "green",
274
+ "blue",
275
+ "indigo",
276
+ "violet",
277
+ "brown",
278
+ "grey",
279
+ "gray",
280
+ )
281
+ for prefix in ("", "light ", "dark ", "medium ", "pale ")
282
+ ),
283
+ )
284
+ COLORS_REMOVE = (
285
+ # filter these out, let's try to be professional here...
286
+ "shit",
287
+ "poop",
288
+ "poo",
289
+ "pee",
290
+ "piss",
291
+ "puke",
292
+ "vomit",
293
+ "snot",
294
+ "booger",
295
+ "bile",
296
+ "diarrhea",
297
+ "icky",
298
+ "sickly",
299
+ )
300
+ COLORS_REPLACE = (
301
+ # prevent registering similar-sounding names
302
+ # these can all be combined
303
+ ("/", " "), # convert [color1]/[color2] to compound (e.g. grey/blue to grey blue)
304
+ ("'s", "s"), # robin's egg
305
+ ("egg blue", "egg"), # robin's egg blue
306
+ ("grey", "gray"), # 'Murica
307
+ ("ochre", "ocher"), # ...
308
+ ("forrest", "forest"), # ...
309
+ ("ocre", "ocher"), # correct spelling
310
+ ("kelley", "kelly"), # ...
311
+ ("reddish", "red"), # remove [color]ish where it modifies the spelling of color
312
+ ("purplish", "purple"), # ...
313
+ ("pinkish", "pink"),
314
+ ("yellowish", "yellow"),
315
+ ("bluish", "blue"),
316
+ ("greyish", "grey"),
317
+ ("ish", ""), # these are all [color]ish ('ish' substring appears nowhere else)
318
+ ("bluey", "blue"), # remove [color]y trailing y
319
+ ("greeny", "green"), # ...
320
+ ("reddy", "red"),
321
+ ("pinky", "pink"),
322
+ ("purply", "purple"),
323
+ ("purpley", "purple"),
324
+ ("yellowy", "yellow"),
325
+ ("orangey", "orange"),
326
+ ("browny", "brown"),
327
+ ("minty", "mint"), # now remove [object]y trailing y
328
+ ("grassy", "grass"), # ...
329
+ ("mossy", "moss"),
330
+ ("dusky", "dusk"),
331
+ ("rusty", "rust"),
332
+ ("muddy", "mud"),
333
+ ("sandy", "sand"),
334
+ ("leafy", "leaf"),
335
+ ("dusty", "dust"),
336
+ ("dirty", "dirt"),
337
+ ("peachy", "peach"),
338
+ ("stormy", "storm"),
339
+ ("cloudy", "cloud"),
340
+ ("grayblue", "gray blue"), # separate merge compounds
341
+ ("bluegray", "gray blue"), # ...
342
+ ("lightblue", "light blue"),
343
+ ("yellowgreen", "yellow green"),
344
+ ("yelloworange", "yellow orange"),
345
+ )
346
+
347
+ # Simple snippets
348
+ _N_docstring = """
349
+ N : int, default: :rc:`image.lut`
350
+ Number of points in the colormap lookup table.
351
+ """
352
+ _alpha_docstring = """
353
+ alpha : float, optional
354
+ The opacity for the entire colormap. This overrides
355
+ the input opacities.
356
+ """
357
+ _cyclic_docstring = """
358
+ cyclic : bool, optional
359
+ Whether the colormap is cyclic. If ``True``, this changes how the leftmost
360
+ and rightmost color levels are selected, and `extend` can only be
361
+ ``'neither'`` (a warning will be issued otherwise).
362
+ """
363
+ _gamma_docstring = """
364
+ gamma : float, optional
365
+ Set `gamma1` and `gamma2` to this identical value.
366
+ gamma1 : float, optional
367
+ If greater than 1, make low saturation colors more prominent. If
368
+ less than 1, make high saturation colors more prominent. Similar to
369
+ the `HCLWizard <http://hclwizard.org:64230/hclwizard/>`_ option.
370
+ gamma2 : float, optional
371
+ If greater than 1, make high luminance colors more prominent. If
372
+ less than 1, make low luminance colors more prominent. Similar to
373
+ the `HCLWizard <http://hclwizard.org:64230/hclwizard/>`_ option.
374
+ """
375
+ _space_docstring = """
376
+ space : {'hsl', 'hpl', 'hcl', 'hsv'}, optional
377
+ The hue, saturation, luminance-style colorspace to use for interpreting
378
+ the channels. See `this page <http://www.hsluv.org/comparison/>`__ for
379
+ a full description.
380
+ """
381
+ _name_docstring = """
382
+ name : str, default: '_no_name'
383
+ The colormap name. This can also be passed as the first
384
+ positional string argument.
385
+ """
386
+ _ratios_docstring = """
387
+ ratios : sequence of float, optional
388
+ Relative extents of each color transition. Must have length
389
+ ``len(colors) - 1``. Larger numbers indicate a slower
390
+ transition, smaller numbers indicate a faster transition.
391
+ """
392
+ docstring._snippet_manager["colors.N"] = _N_docstring
393
+ docstring._snippet_manager["colors.alpha"] = _alpha_docstring
394
+ docstring._snippet_manager["colors.cyclic"] = _cyclic_docstring
395
+ docstring._snippet_manager["colors.gamma"] = _gamma_docstring
396
+ docstring._snippet_manager["colors.space"] = _space_docstring
397
+ docstring._snippet_manager["colors.ratios"] = _ratios_docstring
398
+ docstring._snippet_manager["colors.name"] = _name_docstring
399
+
400
+ # List classmethod snippets
401
+ _from_list_docstring = """
402
+ colors : sequence of color-spec or tuple
403
+ If a sequence of RGB[A] tuples or color strings, the colormap
404
+ transitions evenly from ``colors[0]`` at the left-hand side
405
+ to ``colors[-1]`` at the right-hand side.
406
+
407
+ If a sequence of (float, color-spec) tuples, the float values are the
408
+ coordinate of each transition and must range from 0 to 1. This
409
+ can be used to divide the colormap range unevenly.
410
+ %(colors.name)s
411
+ %(colors.ratios)s
412
+ For example, ``('red', 'blue', 'green')`` with ``ratios=(2, 1)``
413
+ creates a colormap with the transition from red to blue taking
414
+ *twice as long* as the transition from blue to green.
415
+ """
416
+ docstring._snippet_manager["colors.from_list"] = _from_list_docstring
417
+
418
+
419
+ def _clip_colors(colors, clip=True, gray=0.2, warn=False):
420
+ """
421
+ Clip impossible colors rendered in an HSL-to-RGB colorspace
422
+ conversion. Used by `PerceptualColormap`.
423
+
424
+ Parameters
425
+ ----------
426
+ colors : sequence of 3-tuple
427
+ The RGB colors.
428
+ clip : bool, optional
429
+ If `clip` is ``True`` (the default), RGB channel values >1 are
430
+ clipped to 1. Otherwise, the color is masked out as gray.
431
+ gray : float, optional
432
+ The identical RGB channel values (gray color) to be used if
433
+ `clip` is ``True``.
434
+ warn : bool, optional
435
+ Whether to issue warning when colors are clipped.
436
+ """
437
+ colors = np.asarray(colors)
438
+ under = colors < 0
439
+ over = colors > 1
440
+ if clip:
441
+ colors[under], colors[over] = 0, 1
442
+ else:
443
+ colors[under | over] = gray
444
+ if warn:
445
+ msg = "Clipped" if clip else "Invalid"
446
+ for i, name in enumerate("rgb"):
447
+ if np.any(under[:, i]) or np.any(over[:, i]):
448
+ warnings._warn_ultraplot(f"{msg} {name!r} channel.")
449
+ return colors
450
+
451
+
452
+ def _get_channel(color, channel, space="hcl"):
453
+ """
454
+ Get the hue, saturation, or luminance channel value from the input color. The
455
+ color name `color` can optionally be a string with the format ``'color+x'``
456
+ or ``'color-x'``, where `x` is the offset from the channel value.
457
+
458
+ Parameters
459
+ ----------
460
+ color : color-spec
461
+ The color. Sanitized with `to_rgba`.
462
+ channel : optional
463
+ The HCL channel to be retrieved.
464
+ space : optional
465
+ The colorspace for the corresponding channel value.
466
+
467
+ Returns
468
+ -------
469
+ value : float
470
+ The channel value.
471
+ """
472
+ # Interpret channel
473
+ if callable(color) or isinstance(color, Number):
474
+ return color
475
+ if channel == "hue":
476
+ channel = 0
477
+ elif channel in ("chroma", "saturation"):
478
+ channel = 1
479
+ elif channel == "luminance":
480
+ channel = 2
481
+ else:
482
+ raise ValueError(f"Unknown channel {channel!r}.")
483
+ # Interpret string or RGB tuple
484
+ offset = 0
485
+ if isinstance(color, str):
486
+ m = re.search("([-+][0-9.]+)$", color)
487
+ if m:
488
+ offset = float(m.group(0))
489
+ color = color[: m.start()]
490
+ return offset + to_xyz(color, space)[channel]
491
+
492
+
493
+ def _make_segment_data(values, coords=None, ratios=None):
494
+ """
495
+ Return a segmentdata array or callable given the input colors
496
+ and coordinates.
497
+
498
+ Parameters
499
+ ----------
500
+ values : sequence of float
501
+ The channel values.
502
+ coords : sequence of float, optional
503
+ The segment coordinates.
504
+ ratios : sequence of float, optional
505
+ The relative length of each segment transition.
506
+ """
507
+ # Allow callables
508
+ if callable(values):
509
+ return values
510
+ values = np.atleast_1d(values)
511
+ if len(values) == 1:
512
+ value = values[0]
513
+ return [(0, value, value), (1, value, value)]
514
+
515
+ # Get coordinates
516
+ if not np.iterable(values):
517
+ raise TypeError("Colors must be iterable, got {values!r}.")
518
+ if coords is not None:
519
+ coords = np.atleast_1d(coords)
520
+ if ratios is not None:
521
+ warnings._warn_ultraplot(
522
+ f"Segment coordinates were provided, ignoring " f"ratios={ratios!r}."
523
+ )
524
+ if len(coords) != len(values) or coords[0] != 0 or coords[-1] != 1:
525
+ raise ValueError(f"Coordinates must range from 0 to 1, got {coords!r}.")
526
+ elif ratios is not None:
527
+ coords = np.atleast_1d(ratios)
528
+ if len(coords) != len(values) - 1:
529
+ raise ValueError(
530
+ f"Need {len(values) - 1} ratios for {len(values)} colors, "
531
+ f"but got {len(coords)} ratios."
532
+ )
533
+ coords = np.concatenate(([0], np.cumsum(coords)))
534
+ coords = coords / np.max(coords) # normalize to 0-1
535
+ else:
536
+ coords = np.linspace(0, 1, len(values))
537
+
538
+ # Build segmentdata array
539
+ array = []
540
+ for c, value in zip(coords, values):
541
+ array.append((c, value, value))
542
+ return array
543
+
544
+
545
+ def _make_lookup_table(N, data, gamma=1.0, inverse=False):
546
+ r"""
547
+ Generate lookup tables of HSL values given specified gradations. Similar to
548
+ `~matplotlib.colors.makeMappingArray` but permits *circular* hue gradations,
549
+ disables clipping of out-of-bounds values, and uses fancier "gamma" scaling.
550
+
551
+ Parameters
552
+ ----------
553
+ N : int
554
+ Number of points in the colormap lookup table.
555
+ data : array-like
556
+ Sequence of `(x, y_0, y_1)` tuples specifying channel jumps
557
+ (from `y_0` to `y_1`) and `x` coordinate of those jumps
558
+ (ranges between 0 and 1). See `~matplotlib.colors.LinearSegmentedColormap`.
559
+ gamma : float or sequence of float, optional
560
+ To obtain channel values between coordinates `x_i` and `x_{i+1}`
561
+ in rows `i` and `i+1` of `data` we use the formula:
562
+
563
+ .. math::
564
+
565
+ y = y_{1,i} + w_i^{\gamma_i}*(y_{0,i+1} - y_{1,i})
566
+
567
+ where `\gamma_i` corresponds to `gamma` and the weight `w_i` ranges from
568
+ 0 to 1 between rows ``i`` and ``i+1``. If `gamma` is float, it applies
569
+ to every transition. Otherwise, its length must equal ``data.shape[0]-1``.
570
+
571
+ This is similar to the `matplotlib.colors.makeMappingArray` `gamma` except
572
+ it controls the weighting for transitions *between* each segment data
573
+ coordinate rather than the coordinates themselves. This makes more sense
574
+ for `PerceptualColormap`\ s because they usually contain just a
575
+ handful of transitions representing chained segments.
576
+ inverse : bool, optional
577
+ If ``True``, `w_i^{\gamma_i}` is replaced with `1 - (1 - w_i)^{\gamma_i}` --
578
+ that is, when `gamma` is greater than 1, this weights colors toward *higher*
579
+ channel values instead of lower channel values.
580
+
581
+ This is implemented in case we want to apply *equal* "gamma scaling"
582
+ to different HSL channels in different directions. Usually, this
583
+ is done to weight low data values with higher luminance *and* lower
584
+ saturation, thereby emphasizing "extreme" data values.
585
+ """
586
+ # Allow for *callable* instead of linearly interpolating between segments
587
+ gammas = np.atleast_1d(gamma)
588
+ if np.any(gammas < 0.01) or np.any(gammas > 10):
589
+ raise ValueError("Gamma can only be in range [0.01,10].")
590
+ if callable(data):
591
+ if len(gammas) > 1:
592
+ raise ValueError("Only one gamma allowed for functional segmentdata.")
593
+ x = np.linspace(0, 1, N) ** gamma
594
+ lut = np.array(data(x), dtype=float)
595
+ return lut
596
+
597
+ # Get array
598
+ data = np.array(data)
599
+ shape = data.shape
600
+ if len(shape) != 2 or shape[1] != 3:
601
+ raise ValueError("Mapping data must have shape N x 3.")
602
+ if len(gammas) != 1 and len(gammas) != shape[0] - 1:
603
+ raise ValueError(
604
+ f"Expected {shape[0] - 1} gammas for {shape[0]} coords. Got {len(gamma)}."
605
+ ) # noqa: E501
606
+ if len(gammas) == 1:
607
+ gammas = np.repeat(gammas, shape[:1])
608
+
609
+ # Get indices
610
+ x = data[:, 0]
611
+ y0 = data[:, 1]
612
+ y1 = data[:, 2]
613
+ if x[0] != 0.0 or x[-1] != 1.0:
614
+ raise ValueError("Data mapping points must start with x=0 and end with x=1.")
615
+ if np.any(np.diff(x) < 0):
616
+ raise ValueError("Data mapping points must have x in increasing order.")
617
+ x = x * (N - 1)
618
+
619
+ # Get distances from the segmentdata entry to the *left* for each requested
620
+ # level, excluding ends at (0, 1), which must exactly match segmentdata ends.
621
+ # NOTE: numpy.searchsorted returns where xq[i] must be inserted so it is
622
+ # larger than x[ind[i]-1] but smaller than x[ind[i]].
623
+ xq = (N - 1) * np.linspace(0, 1, N)
624
+ ind = np.searchsorted(x, xq)[1:-1]
625
+ offsets = (xq[1:-1] - x[ind - 1]) / (x[ind] - x[ind - 1])
626
+
627
+ # Scale distances in each segment by input gamma
628
+ # The ui are starting-points, the ci are counts from that point over which
629
+ # segment applies (i.e. where to apply the gamma), the relevant 'segment'
630
+ # is to the *left* of index returned by searchsorted
631
+ _, uind, cind = np.unique(ind, return_index=True, return_counts=True)
632
+ for ui, ci in zip(uind, cind): # length should be N-1
633
+ gamma = gammas[ind[ui] - 1] # the relevant segment is *left* of this number
634
+ if gamma == 1:
635
+ continue
636
+ if ci == 0: # no lookup table coordinates fall inside this segment
637
+ reverse = False
638
+ else: # reverse if we are transitioning to *lower* channel value
639
+ reverse = (y0[ind[ui]] - y1[ind[ui] - 1]) < 0
640
+ if inverse: # reverse if we are transitioning to *higher* channel value
641
+ reverse = not reverse
642
+ if reverse:
643
+ offsets[ui : ui + ci] = 1 - (1 - offsets[ui : ui + ci]) ** gamma
644
+ else:
645
+ offsets[ui : ui + ci] **= gamma
646
+
647
+ # Perform successive linear interpolations rolled up into one equation
648
+ lut = np.zeros((N,), float)
649
+ lut[1:-1] = y1[ind - 1] + offsets * (y0[ind] - y1[ind - 1])
650
+ lut[0] = y1[0]
651
+ lut[-1] = y0[-1]
652
+ return lut
653
+
654
+
655
+ def _load_colors(path, warn_on_failure=True):
656
+ """
657
+ Read colors from the input file.
658
+
659
+ Parameters
660
+ ----------
661
+ warn_on_failure : bool, optional
662
+ If ``True``, issue a warning when loading fails instead of raising an error.
663
+ """
664
+ # Warn or raise error (matches Colormap._from_file behavior)
665
+ if not os.path.exists(path):
666
+ message = f"Failed to load color data file {path!r}. File not found."
667
+ if warn_on_failure:
668
+ warnings._warn_ultraplot(message)
669
+ else:
670
+ raise FileNotFoundError(message)
671
+
672
+ # Iterate through lines
673
+ loaded = {}
674
+ with open(path, "r") as fh:
675
+ for count, line in enumerate(fh):
676
+ stripped = line.strip()
677
+ if not stripped or stripped[0] == "#":
678
+ continue
679
+ pair = tuple(item.strip().lower() for item in line.split(":"))
680
+ if len(pair) != 2 or not REGEX_HEX_SINGLE.match(pair[1]):
681
+ warnings._warn_ultraplot(
682
+ f"Illegal line #{count + 1} in color file {path!r}:\n"
683
+ f"{line!r}\n"
684
+ f'Lines must be formatted as "name: hexcolor".'
685
+ )
686
+ continue
687
+ loaded[pair[0]] = pair[1]
688
+ return loaded
689
+
690
+
691
+ def _standardize_colors(input, space, margin):
692
+ """
693
+ Standardize the input colors.
694
+
695
+ Parameters
696
+ ----------
697
+ input : dict
698
+ The colors.
699
+ space : optional
700
+ The colorspace used to filter colors.
701
+ margin : optional
702
+ The proportional margin required for unique colors (e.g. 0.1
703
+ is 36 hue units, 10 saturation units, 10 luminance units).
704
+ """
705
+ output = {}
706
+ colors = []
707
+ channels = []
708
+
709
+ # Always add these colors and ignore other colors that are too close
710
+ # We do this for colors with nice names or that ultraplot devs really like
711
+ for name in COLORS_KEEP:
712
+ color = input.pop(name, None)
713
+ if color is None:
714
+ continue
715
+ if "grey" in name:
716
+ name = name.replace("grey", "gray")
717
+ colors.append((name, color))
718
+ channels.append(to_xyz(color, space=space))
719
+ output[name] = color # required in case "kept" colors are close to each other
720
+
721
+ # Translate remaining colors and remove bad names
722
+ # WARNING: Unique axis argument requires numpy version >=1.13
723
+ for name, color in input.items():
724
+ for sub, rep in COLORS_REPLACE:
725
+ if sub in name:
726
+ name = name.replace(sub, rep)
727
+ if any(sub in name for sub in COLORS_REMOVE):
728
+ continue # remove "unpofessional" names
729
+ if name in output:
730
+ continue # prioritize names that come first
731
+ colors.append((name, color)) # category name pair
732
+ channels.append(to_xyz(color, space=space))
733
+
734
+ # Get locations of "perceptually distinct" colors
735
+ channels = np.asarray(channels)
736
+ if not channels.size:
737
+ return output
738
+ channels = channels / np.array([360, 100, 100])
739
+ channels = np.round(channels / margin).astype(np.int64)
740
+ _, idxs = np.unique(channels, return_index=True, axis=0)
741
+
742
+ # Return only "distinct" colors
743
+ for idx in idxs:
744
+ name, color = colors[idx]
745
+ output[name] = color
746
+ return output
747
+
748
+
749
+ class _Colormap(object):
750
+ """
751
+ Mixin class used to add some helper methods.
752
+ """
753
+
754
+ def _get_data(self, ext, alpha=True):
755
+ """
756
+ Return a string containing the colormap colors for saving.
757
+
758
+ Parameters
759
+ ----------
760
+ ext : {'hex', 'txt', 'rgb'}
761
+ The filename extension.
762
+ alpha : bool, optional
763
+ Whether to include an opacity column.
764
+ """
765
+ # Get lookup table colors and filter out bad ones
766
+ if not self._isinit:
767
+ self._init()
768
+ colors = self._lut[:-3, :]
769
+
770
+ # Get data string
771
+ if ext == "hex":
772
+ data = ", ".join(mcolors.to_hex(color) for color in colors)
773
+ elif ext in ("txt", "rgb"):
774
+ rgb = mcolors.to_rgba if alpha else mcolors.to_rgb
775
+ data = [rgb(color) for color in colors]
776
+ data = "\n".join(" ".join(f"{num:0.6f}" for num in line) for line in data)
777
+ else:
778
+ raise ValueError(
779
+ f"Invalid extension {ext!r}. Options are: "
780
+ "'hex', 'txt', 'rgb', 'rgba'."
781
+ )
782
+ return data
783
+
784
+ def _make_name(self, suffix=None):
785
+ """
786
+ Generate a default colormap name. Do not append more than one
787
+ leading underscore or more than one identical suffix.
788
+ """
789
+ name = self.name
790
+ name = name or ""
791
+ if name[:1] != "_":
792
+ name = "_" + name
793
+ suffix = suffix or "copy"
794
+ suffix = "_" + suffix
795
+ if name[-len(suffix) :] != suffix:
796
+ name = name + suffix
797
+ return name
798
+
799
+ def _parse_path(self, path, ext=None, subfolder=None):
800
+ """
801
+ Parse the user input path.
802
+
803
+ Parameters
804
+ ----------
805
+ path : path-like, optional
806
+ The file path.
807
+ ext : str
808
+ The default extension.
809
+ subfolder : str, optional
810
+ The subfolder.
811
+ """
812
+ # Get the folder
813
+ folder = rc.user_folder(subfolder=subfolder)
814
+ if path is not None:
815
+ path = os.path.expanduser(path or ".") # interpret empty string as '.'
816
+ if os.path.isdir(path):
817
+ folder, path = path, None
818
+ # Get the filename
819
+ if path is None:
820
+ path = os.path.join(folder, self.name)
821
+ if not os.path.splitext(path)[1]:
822
+ path = path + "." + ext # default file extension
823
+ return path
824
+
825
+ @staticmethod
826
+ def _pop_args(*args, names=None, **kwargs):
827
+ """
828
+ Pop the name as a first positional argument or keyword argument.
829
+ Supports matplotlib-style ``Colormap(name, data, N)`` input
830
+ algongside more intuitive ``Colormap(data, name, N)`` input.
831
+ """
832
+ names = names or ()
833
+ if isinstance(names, str):
834
+ names = (names,)
835
+ names = ("name", *names)
836
+ args, kwargs = _kwargs_to_args(names, *args, **kwargs)
837
+ if args[0] is not None and args[1] is None:
838
+ args[:2] = (None, args[0])
839
+ if args[0] is None:
840
+ args[0] = DEFAULT_NAME
841
+ return (*args, kwargs)
842
+
843
+ @classmethod
844
+ def _from_file(cls, path, warn_on_failure=False):
845
+ """
846
+ Read generalized colormap and color cycle files.
847
+ """
848
+ path = os.path.expanduser(path)
849
+ name, ext = os.path.splitext(os.path.basename(path))
850
+ listed = issubclass(cls, mcolors.ListedColormap)
851
+ reversed = name[-2:] == "_r"
852
+
853
+ # Warn if loading failed during `register_cmaps` or `register_cycles`
854
+ # but raise error if user tries to load a file.
855
+ def _warn_or_raise(descrip, error=RuntimeError):
856
+ prefix = f"Failed to load colormap or color cycle file {path!r}."
857
+ if warn_on_failure:
858
+ warnings._warn_ultraplot(prefix + " " + descrip)
859
+ else:
860
+ raise error(prefix + " " + descrip)
861
+
862
+ if not os.path.exists(path):
863
+ return _warn_or_raise("File not found.", FileNotFoundError)
864
+
865
+ # Directly read segmentdata json file
866
+ # NOTE: This is special case! Immediately return name and cmap
867
+ ext = ext[1:]
868
+ if ext == "json":
869
+ if listed:
870
+ return _warn_or_raise("Cannot load cycles from JSON files.")
871
+ try:
872
+ with open(path, "r") as fh:
873
+ data = json.load(fh)
874
+ except json.JSONDecodeError:
875
+ return _warn_or_raise("JSON decoding error.", json.JSONDecodeError)
876
+ kw = {}
877
+ for key in ("cyclic", "gamma", "gamma1", "gamma2", "space"):
878
+ if key in data:
879
+ kw[key] = data.pop(key, None)
880
+ if "red" in data:
881
+ cmap = ContinuousColormap(name, data)
882
+ else:
883
+ cmap = PerceptualColormap(name, data, **kw)
884
+ if reversed:
885
+ cmap = cmap.reversed(name[:-2])
886
+ return cmap
887
+
888
+ # Read .rgb and .rgba files
889
+ if ext in ("txt", "rgb"):
890
+ # Load file
891
+ # NOTE: This appears to be biggest import time bottleneck! Increases
892
+ # time from 0.05s to 0.2s, with numpy loadtxt or with this regex thing.
893
+ delim = re.compile(r"[,\s]+")
894
+ data = [
895
+ delim.split(line.strip())
896
+ for line in open(path)
897
+ if line.strip() and line.strip()[0] != "#"
898
+ ]
899
+ try:
900
+ data = [[float(num) for num in line] for line in data]
901
+ except ValueError:
902
+ return _warn_or_raise(
903
+ "Expected a table of comma or space-separated floats."
904
+ )
905
+ # Build x-coordinates and standardize shape
906
+ data = np.array(data)
907
+ if data.shape[1] not in (3, 4):
908
+ return _warn_or_raise(
909
+ f"Expected 3 or 4 columns of floats. Got {data.shape[1]} columns."
910
+ )
911
+ if ext[0] != "x": # i.e. no x-coordinates specified explicitly
912
+ x = np.linspace(0, 1, data.shape[0])
913
+ else:
914
+ x, data = data[:, 0], data[:, 1:]
915
+
916
+ # Load XML files created with scivizcolor
917
+ # Adapted from script found here:
918
+ # https://sciviscolor.org/matlab-matplotlib-pv44/
919
+ elif ext == "xml":
920
+ try:
921
+ doc = ElementTree.parse(path)
922
+ except ElementTree.ParseError:
923
+ return _warn_or_raise("XML parsing error.", ElementTree.ParseError)
924
+ x, data = [], []
925
+ for s in doc.getroot().findall(".//Point"):
926
+ # Verify keys
927
+ if any(key not in s.attrib for key in "xrgb"):
928
+ return _warn_or_raise(
929
+ "Missing an x, r, g, or b key inside one or more <Point> tags."
930
+ )
931
+ # Get data
932
+ color = []
933
+ for key in "rgbao": # o for opacity
934
+ if key not in s.attrib:
935
+ continue
936
+ color.append(float(s.attrib[key]))
937
+ x.append(float(s.attrib["x"]))
938
+ data.append(color)
939
+ # Convert to array
940
+ if not all(
941
+ len(data[0]) == len(color) and len(color) in (3, 4) for color in data
942
+ ):
943
+ return _warn_or_raise(
944
+ "Unexpected channel number or mixed channels across <Point> tags."
945
+ )
946
+
947
+ # Read hex strings
948
+ elif ext == "hex":
949
+ # Read arbitrary format
950
+ string = open(path).read() # into single string
951
+ data = REGEX_HEX_MULTI.findall(string)
952
+ if len(data) < 2:
953
+ return _warn_or_raise("Failed to find 6-digit or 8-digit HEX strings.")
954
+ # Convert to array
955
+ x = np.linspace(0, 1, len(data))
956
+ data = [to_rgb(color) for color in data]
957
+
958
+ # Invalid extension
959
+ else:
960
+ return _warn_or_raise(
961
+ "Unknown colormap file extension {ext!r}. Options are: "
962
+ + ", ".join(map(repr, ("json", "txt", "rgb", "hex")))
963
+ + "."
964
+ )
965
+
966
+ # Standardize and reverse if necessary to cmap
967
+ # TODO: Document the fact that filenames ending in _r return a reversed
968
+ # version of the colormap stored in that file.
969
+ x = np.array(x)
970
+ x = (x - x.min()) / (x.max() - x.min()) # ensure they span 0-1
971
+ data = np.array(data)
972
+ if np.any(data > 2): # from 0-255 to 0-1
973
+ data = data / 255
974
+ if reversed:
975
+ name = name[:-2]
976
+ data = data[::-1, :]
977
+ x = 1 - x[::-1]
978
+ if listed:
979
+ return DiscreteColormap(data, name)
980
+ else:
981
+ data = [(x, color) for x, color in zip(x, data)]
982
+ return ContinuousColormap.from_list(name, data)
983
+
984
+
985
+ class ContinuousColormap(mcolors.LinearSegmentedColormap, _Colormap):
986
+ r"""
987
+ Replacement for `~matplotlib.colors.LinearSegmentedColormap`.
988
+ """
989
+
990
+ def __str__(self):
991
+ return type(self).__name__ + f"(name={self.name!r})"
992
+
993
+ def __repr__(self):
994
+ string = f" 'name': {self.name!r},\n"
995
+ if hasattr(self, "_space"):
996
+ string += f" 'space': {self._space!r},\n"
997
+ if hasattr(self, "_cyclic"):
998
+ string += f" 'cyclic': {self._cyclic!r},\n"
999
+ for key, data in self._segmentdata.items():
1000
+ if callable(data):
1001
+ string += f" {key!r}: <function>,\n"
1002
+ else:
1003
+ stop = data[-1][1]
1004
+ start = data[0][2]
1005
+ string += f" {key!r}: [{start:.2f}, ..., {stop:.2f}],\n"
1006
+ return type(self).__name__ + "({\n" + string + "})"
1007
+
1008
+ @docstring._snippet_manager
1009
+ def __init__(self, *args, gamma=1, alpha=None, cyclic=False, **kwargs):
1010
+ """
1011
+ Parameters
1012
+ ----------
1013
+ segmentdata : dict-like
1014
+ Dictionary containing the keys ``'red'``, ``'green'``, ``'blue'``, and
1015
+ (optionally) ``'alpha'``. The shorthands ``'r'``, ``'g'``, ``'b'``,
1016
+ and ``'a'`` are also acceptable. The key values can be callable
1017
+ functions that return channel values given a colormap index, or
1018
+ 3-column arrays indicating the coordinates and channel transitions. See
1019
+ `matplotlib.colors.LinearSegmentedColormap` for a detailed explanation.
1020
+ %(colors.name)s
1021
+ %(colors.N)s
1022
+ gamma : float, optional
1023
+ Gamma scaling used for the *x* coordinates.
1024
+ %(colors.alpha)s
1025
+ %(colors.cyclic)s
1026
+
1027
+ Other parameters
1028
+ ----------------
1029
+ **kwargs
1030
+ Passed to `matplotlib.colors.LinearSegmentedColormap`.
1031
+
1032
+ See also
1033
+ --------
1034
+ DiscreteColormap
1035
+ matplotlib.colors.LinearSegmentedColormap
1036
+ ultraplot.constructor.Colormap
1037
+ """
1038
+ # NOTE: Additional keyword args should raise matplotlib error
1039
+ name, segmentdata, N, kwargs = self._pop_args(
1040
+ *args, names=("segmentdata", "N"), **kwargs
1041
+ )
1042
+ if not isinstance(segmentdata, dict):
1043
+ raise ValueError(f"Invalid segmentdata {segmentdata}. Must be a dict.")
1044
+ N = _not_none(N, rc["image.lut"])
1045
+ data = _pop_props(segmentdata, "rgba", "hsla")
1046
+ if segmentdata:
1047
+ raise ValueError(f"Invalid segmentdata keys {tuple(segmentdata)}.")
1048
+ super().__init__(name, data, N=N, gamma=gamma, **kwargs)
1049
+ self._cyclic = cyclic
1050
+ if alpha is not None:
1051
+ self.set_alpha(alpha)
1052
+
1053
+ def append(self, *args, ratios=None, name=None, N=None, **kwargs):
1054
+ """
1055
+ Return the concatenation of this colormap with the
1056
+ input colormaps.
1057
+
1058
+ Parameters
1059
+ ----------
1060
+ *args
1061
+ Instances of `ContinuousColormap`.
1062
+ ratios : sequence of float, optional
1063
+ Relative extent of each component colormap in the
1064
+ merged colormap. Length must equal ``len(args) + 1``.
1065
+ For example, ``cmap1.append(cmap2, ratios=(2, 1))`` generates
1066
+ a colormap with the left two-thrids containing colors from
1067
+ ``cmap1`` and the right one-third containing colors from ``cmap2``.
1068
+ name : str, optional
1069
+ The colormap name. Default is to merge each name with underscores and
1070
+ prepend a leading underscore, for example ``_name1_name2``.
1071
+ N : int, optional
1072
+ The number of points in the colormap lookup table. Default is
1073
+ to sum the length of each lookup table.
1074
+
1075
+ Other parameters
1076
+ ----------------
1077
+ **kwargs
1078
+ Passed to `ContinuousColormap.copy`
1079
+ or `PerceptualColormap.copy`.
1080
+
1081
+ Returns
1082
+ -------
1083
+ ContinuousColormap
1084
+ The colormap.
1085
+
1086
+ See also
1087
+ --------
1088
+ DiscreteColormap.append
1089
+ """
1090
+ # Parse input args
1091
+ if not args:
1092
+ return self
1093
+ if not all(isinstance(cmap, mcolors.LinearSegmentedColormap) for cmap in args):
1094
+ raise TypeError(f"Arguments {args!r} must be LinearSegmentedColormaps.")
1095
+
1096
+ # PerceptualColormap --> ContinuousColormap conversions
1097
+ cmaps = [self, *args]
1098
+ spaces = {getattr(cmap, "_space", None) for cmap in cmaps}
1099
+ to_continuous = len(spaces) > 1 # mixed colorspaces *or* mixed types
1100
+ if to_continuous:
1101
+ for i, cmap in enumerate(cmaps):
1102
+ if isinstance(cmap, PerceptualColormap):
1103
+ cmaps[i] = cmap.to_continuous()
1104
+
1105
+ # Combine the segmentdata, and use the y1/y2 slots at merge points so
1106
+ # we never interpolate between end colors of different colormaps
1107
+ segmentdata = {}
1108
+ if name is None:
1109
+ name = "_" + "_".join(cmap.name for cmap in cmaps)
1110
+ if not np.iterable(ratios):
1111
+ ratios = [1] * len(cmaps)
1112
+ ratios = np.asarray(ratios) / np.sum(ratios)
1113
+ x0 = np.append(0, np.cumsum(ratios)) # coordinates for edges
1114
+ xw = x0[1:] - x0[:-1] # widths between edges
1115
+ for key in cmaps[0]._segmentdata.keys(): # not self._segmentdata
1116
+ # Callable segments
1117
+ # WARNING: If just reference a global 'funcs' list from inside the
1118
+ # 'data' function it can get overwritten in this loop. Must
1119
+ # embed 'funcs' into the definition using a keyword argument.
1120
+ datas = [cmap._segmentdata[key] for cmap in cmaps]
1121
+ if all(map(callable, datas)): # expand range from x-to-w to 0-1
1122
+
1123
+ def xyy(ix, funcs=datas): # noqa: E306
1124
+ ix = np.atleast_1d(ix)
1125
+ kx = np.empty(ix.shape)
1126
+ for j, jx in enumerate(ix.flat):
1127
+ idx = max(np.searchsorted(x0, jx) - 1, 0)
1128
+ kx.flat[j] = funcs[idx]((jx - x0[idx]) / xw[idx])
1129
+ return kx
1130
+
1131
+ # Concatenate segment arrays and make the transition at the
1132
+ # seam instant so we *never interpolate* between end colors
1133
+ # of different maps.
1134
+ elif not any(map(callable, datas)):
1135
+ datas = []
1136
+ for x, w, cmap in zip(x0[:-1], xw, cmaps):
1137
+ xyy = np.array(cmap._segmentdata[key])
1138
+ xyy[:, 0] = x + w * xyy[:, 0]
1139
+ datas.append(xyy)
1140
+ for i in range(len(datas) - 1):
1141
+ datas[i][-1, 2] = datas[i + 1][0, 2]
1142
+ datas[i + 1] = datas[i + 1][1:, :]
1143
+ xyy = np.concatenate(datas, axis=0)
1144
+ xyy[:, 0] = xyy[:, 0] / xyy[:, 0].max(axis=0) # fix fp errors
1145
+
1146
+ else:
1147
+ raise TypeError(
1148
+ "Cannot merge colormaps with mixed callable "
1149
+ "and non-callable segment data."
1150
+ )
1151
+ segmentdata[key] = xyy
1152
+
1153
+ # Handle gamma values
1154
+ ikey = None
1155
+ if key == "saturation":
1156
+ ikey = "gamma1"
1157
+ elif key == "luminance":
1158
+ ikey = "gamma2"
1159
+ if not ikey or ikey in kwargs:
1160
+ continue
1161
+ gamma = []
1162
+ callable_ = all(map(callable, datas))
1163
+ for cmap in cmaps:
1164
+ igamma = getattr(cmap, "_" + ikey)
1165
+ if not np.iterable(igamma):
1166
+ if callable_:
1167
+ igamma = (igamma,)
1168
+ else:
1169
+ igamma = (igamma,) * (len(cmap._segmentdata[key]) - 1)
1170
+ gamma.extend(igamma)
1171
+ if callable_:
1172
+ if any(igamma != gamma[0] for igamma in gamma[1:]):
1173
+ warnings._warn_ultraplot(
1174
+ "Cannot use multiple segment gammas when concatenating "
1175
+ f"callable segments. Using the first gamma of {gamma[0]}."
1176
+ )
1177
+ gamma = gamma[0]
1178
+ kwargs[ikey] = gamma
1179
+
1180
+ # Return copy or merge mixed types
1181
+ if to_continuous and isinstance(self, PerceptualColormap):
1182
+ return ContinuousColormap(name, segmentdata, N, **kwargs)
1183
+ else:
1184
+ return self.copy(name, segmentdata, N, **kwargs)
1185
+
1186
+ def cut(self, cut=None, name=None, left=None, right=None, **kwargs):
1187
+ """
1188
+ Return a version of the colormap with the center "cut out".
1189
+ This is great for making the transition from "negative" to "positive"
1190
+ in a diverging colormap more distinct.
1191
+
1192
+ Parameters
1193
+ ----------
1194
+ cut : float, optional
1195
+ The proportion to cut from the center of the colormap. For example,
1196
+ ``cut=0.1`` cuts the central 10%, or ``cut=-0.1`` fills the ctranl 10%
1197
+ of the colormap with the current central color (usually white).
1198
+ name : str, default: '_name_copy'
1199
+ The new colormap name.
1200
+ left, right : float, default: 0, 1
1201
+ The colormap indices for the "leftmost" and "rightmost"
1202
+ colors. See `~ContinuousColormap.truncate` for details.
1203
+ right : float, optional
1204
+ The colormap index for the new "rightmost" color. Must fall between
1205
+
1206
+ Other parameters
1207
+ ----------------
1208
+ **kwargs
1209
+ Passed to `ContinuousColormap.copy` or `PerceptualColormap.copy`.
1210
+
1211
+ Returns
1212
+ -------
1213
+ ContinuousColormap
1214
+ The colormap.
1215
+
1216
+ See also
1217
+ --------
1218
+ ContinuousColormap.truncate
1219
+ DiscreteColormap.truncate
1220
+ """
1221
+ # Parse input args
1222
+ left = max(_not_none(left, 0), 0)
1223
+ right = min(_not_none(right, 1), 1)
1224
+ cut = _not_none(cut, 0)
1225
+ offset = 0.5 * cut
1226
+ if offset < 0: # add extra 'white' later on
1227
+ offset = 0
1228
+ elif offset == 0:
1229
+ return self.truncate(left, right)
1230
+
1231
+ # Decompose cut into two truncations followed by concatenation
1232
+ if 0.5 - offset < left or 0.5 + offset > right:
1233
+ raise ValueError(f"Invalid cut={cut} for left={left} and right={right}.")
1234
+ if name is None:
1235
+ name = self._make_name()
1236
+ cmap_left = self.truncate(left, 0.5 - offset)
1237
+ cmap_right = self.truncate(0.5 + offset, right)
1238
+
1239
+ # Permit adding extra 'white' to colormap center
1240
+ # NOTE: Rely on channel abbreviations to simplify code here
1241
+ args = []
1242
+ if cut < 0:
1243
+ ratio = 0.5 - 0.5 * abs(cut) # ratio for flanks on either side
1244
+ space = getattr(self, "_space", None) or "rgb"
1245
+ xyza = to_xyza(self(0.5), space=space)
1246
+ segmentdata = {
1247
+ key: _make_segment_data(x) for key, x in zip(space + "a", xyza)
1248
+ }
1249
+ args.append(type(self)(DEFAULT_NAME, segmentdata, self.N))
1250
+ kwargs.setdefault("ratios", (ratio, abs(cut), ratio))
1251
+ args.append(cmap_right)
1252
+
1253
+ return cmap_left.append(*args, name=name, **kwargs)
1254
+
1255
+ def reversed(self, name=None, **kwargs):
1256
+ """
1257
+ Return a reversed copy of the colormap.
1258
+
1259
+ Parameters
1260
+ ----------
1261
+ name : str, default: '_name_r'
1262
+ The new colormap name.
1263
+
1264
+ Other parameters
1265
+ ----------------
1266
+ **kwargs
1267
+ Passed to `ContinuousColormap.copy`
1268
+ or `PerceptualColormap.copy`.
1269
+
1270
+ See also
1271
+ --------
1272
+ matplotlib.colors.LinearSegmentedColormap.reversed
1273
+ """
1274
+ # Reverse segments
1275
+ segmentdata = {
1276
+ key: (
1277
+ (lambda x, func=data: func(x))
1278
+ if callable(data)
1279
+ else [(1.0 - x, y1, y0) for x, y0, y1 in reversed(data)]
1280
+ )
1281
+ for key, data in self._segmentdata.items()
1282
+ }
1283
+
1284
+ # Reverse gammas
1285
+ if name is None:
1286
+ name = self._make_name(suffix="r")
1287
+ for key in ("gamma1", "gamma2"):
1288
+ if key in kwargs:
1289
+ continue
1290
+ gamma = getattr(self, "_" + key, None)
1291
+ if gamma is not None and np.iterable(gamma):
1292
+ kwargs[key] = gamma[::-1]
1293
+
1294
+ cmap = self.copy(name, segmentdata, **kwargs)
1295
+ cmap._rgba_under, cmap._rgba_over = cmap._rgba_over, cmap._rgba_under
1296
+ return cmap
1297
+
1298
+ @docstring._snippet_manager
1299
+ def save(self, path=None, alpha=True):
1300
+ """
1301
+ Save the colormap data to a file.
1302
+
1303
+ Parameters
1304
+ ----------
1305
+ path : path-like, optional
1306
+ The output filename. If not provided, the colormap is saved in the
1307
+ ``cmaps`` subfolder in `~ultraplot.config.Configurator.user_folder`
1308
+ under the filename ``name.json`` (where ``name`` is the colormap
1309
+ name). Valid extensions are shown in the below table.
1310
+
1311
+ %(rc.cmap_exts)s
1312
+
1313
+ alpha : bool, optional
1314
+ Whether to include an opacity column for ``.rgb``
1315
+ and ``.txt`` files.
1316
+
1317
+ See also
1318
+ --------
1319
+ DiscreteColormap.save
1320
+ """
1321
+ # NOTE: We sanitize segmentdata before saving to json. Convert numpy float to
1322
+ # builtin float, np.array to list of lists, and callable to list of lists.
1323
+ # We tried encoding func.__code__ with base64 and marshal instead, but when
1324
+ # cmap.append() embeds functions as keyword arguments, this seems to make it
1325
+ # *impossible* to load back up the function with FunctionType (error message:
1326
+ # arg 5 (closure) must be tuple). Instead use this brute force workaround.
1327
+ filename = self._parse_path(path, ext="json", subfolder="cmaps")
1328
+ _, ext = os.path.splitext(filename)
1329
+ if ext[1:] != "json":
1330
+ # Save lookup table colors
1331
+ data = self._get_data(ext[1:], alpha=alpha)
1332
+ with open(filename, "w") as fh:
1333
+ fh.write(data)
1334
+ else:
1335
+ # Save segment data itself
1336
+ data = {}
1337
+ for key, value in self._segmentdata.items():
1338
+ if callable(value):
1339
+ x = np.linspace(0, 1, rc["image.lut"]) # just save the transitions
1340
+ y = np.array([value(_) for _ in x]).squeeze()
1341
+ value = np.vstack((x, y, y)).T
1342
+ data[key] = np.asarray(value).astype(float).tolist()
1343
+ keys = ()
1344
+ if isinstance(self, PerceptualColormap):
1345
+ keys = ("cyclic", "gamma1", "gamma2", "space")
1346
+ elif isinstance(self, ContinuousColormap):
1347
+ keys = ("cyclic", "gamma")
1348
+ for key in keys: # add all attrs to dictionary
1349
+ data[key] = getattr(self, "_" + key)
1350
+ with open(filename, "w") as fh:
1351
+ json.dump(data, fh, indent=4)
1352
+ print(f"Saved colormap to {filename!r}.")
1353
+
1354
+ def set_alpha(self, alpha, coords=None, ratios=None):
1355
+ """
1356
+ Set the opacity for the entire colormap or set up an opacity gradation.
1357
+
1358
+ Parameters
1359
+ ----------
1360
+ alpha : float or sequence of float
1361
+ If float, this is the opacity for the entire colormap. If sequence of
1362
+ float, the colormap traverses these opacity values.
1363
+ coords : sequence of float, optional
1364
+ Colormap coordinates for the opacity values. The first and last
1365
+ coordinates must be ``0`` and ``1``. If `alpha` is not scalar, the
1366
+ default coordinates are ``np.linspace(0, 1, len(alpha))``.
1367
+ ratios : sequence of float, optional
1368
+ Relative extent of each opacity transition segment. Length should
1369
+ equal ``len(alpha) + 1``. For example
1370
+ ``cmap.set_alpha((1, 1, 0), ratios=(2, 1))`` creates a transtion from
1371
+ 100 percent to 0 percent opacity in the right *third* of the colormap.
1372
+
1373
+ See also
1374
+ --------
1375
+ DiscreteColormap.set_alpha
1376
+ """
1377
+ alpha = _make_segment_data(alpha, coords=coords, ratios=ratios)
1378
+ self._segmentdata["alpha"] = alpha
1379
+ self._isinit = False
1380
+
1381
+ def set_cyclic(self, b):
1382
+ """
1383
+ Set whether this colormap is "cyclic". See `ContinuousColormap` for details.
1384
+ """
1385
+ self._cyclic = bool(b)
1386
+ self._isinit = False
1387
+
1388
+ def shifted(self, shift=180, name=None, **kwargs):
1389
+ """
1390
+ Return a cyclicaly shifted version of the colormap. If the colormap
1391
+ cyclic property is set to ``False`` a warning will be raised.
1392
+
1393
+ Parameters
1394
+ ----------
1395
+ shift : float, default: 180
1396
+ The number of degrees to shift, out of 360 degrees.
1397
+ name : str, default: '_name_s'
1398
+ The new colormap name.
1399
+
1400
+ Other parameters
1401
+ ----------------
1402
+ **kwargs
1403
+ Passed to `ContinuousColormap.copy` or `PerceptualColormap.copy`.
1404
+
1405
+ See also
1406
+ --------
1407
+ DiscreteColormap.shifted
1408
+ """
1409
+ shift = shift or 0
1410
+ shift %= 360
1411
+ shift /= 360
1412
+ if shift == 0:
1413
+ return self
1414
+ if name is None:
1415
+ name = self._make_name(suffix="s")
1416
+ if not self._cyclic:
1417
+ warnings._warn_ultraplot(
1418
+ f"Shifting non-cyclic colormap {self.name!r}. To suppress this "
1419
+ "warning use cmap.set_cyclic(True) or Colormap(..., cyclic=True)."
1420
+ )
1421
+ self._cyclic = True
1422
+ ratios = (1 - shift, shift)
1423
+ cmap_left = self.truncate(shift, 1)
1424
+ cmap_right = self.truncate(0, shift)
1425
+ return cmap_left.append(cmap_right, ratios=ratios, name=name, **kwargs)
1426
+
1427
+ def truncate(self, left=None, right=None, name=None, **kwargs):
1428
+ """
1429
+ Return a truncated version of the colormap.
1430
+
1431
+ Parameters
1432
+ ----------
1433
+ left : float, default: 0
1434
+ The colormap index for the new "leftmost" color. Must fall between ``0``
1435
+ and ``1``. For example, ``left=0.1`` cuts the leftmost 10%% of the colors.
1436
+ right : float, default: 1
1437
+ The colormap index for the new "rightmost" color. Must fall between ``0``
1438
+ and ``1``. For example, ``right=0.9`` cuts the leftmost 10%% of the colors.
1439
+ name : str, default: '_name_copy'
1440
+ The new colormap name.
1441
+
1442
+ Other parameters
1443
+ ----------------
1444
+ **kwargs
1445
+ Passed to `ContinuousColormap.copy`
1446
+ or `PerceptualColormap.copy`.
1447
+
1448
+ See also
1449
+ --------
1450
+ DiscreteColormap.truncate
1451
+ """
1452
+ # Bail out
1453
+ left = max(_not_none(left, 0), 0)
1454
+ right = min(_not_none(right, 1), 1)
1455
+ if left == 0 and right == 1:
1456
+ return self
1457
+ if name is None:
1458
+ name = self._make_name()
1459
+
1460
+ # Resample the segmentdata arrays
1461
+ segmentdata = {}
1462
+ for key, data in self._segmentdata.items():
1463
+ # Callable array
1464
+ # WARNING: If just reference a global 'xyy' callable from inside
1465
+ # the lambda function it gets overwritten in the loop! Must embed
1466
+ # the old callable in the new one as a default keyword arg.
1467
+ if callable(data):
1468
+
1469
+ def xyy(x, func=data):
1470
+ return func(left + x * (right - left))
1471
+
1472
+ # Slice
1473
+ # l is the first point where x > 0 or x > left, should be >= 1
1474
+ # r is the last point where r < 1 or r < right
1475
+ else:
1476
+ xyy = np.asarray(data)
1477
+ x = xyy[:, 0]
1478
+ l = np.searchsorted(x, left) # first x value > left # noqa
1479
+ r = np.searchsorted(x, right) - 1 # last x value < right
1480
+ xc = xyy[l : r + 1, :].copy()
1481
+ xl = xyy[l - 1, 1:] + (left - x[l - 1]) * (
1482
+ (xyy[l, 1:] - xyy[l - 1, 1:]) / (x[l] - x[l - 1])
1483
+ )
1484
+ xr = xyy[r, 1:] + (right - x[r]) * (
1485
+ (xyy[r + 1, 1:] - xyy[r, 1:]) / (x[r + 1] - x[r])
1486
+ )
1487
+ xyy = np.vstack(((left, *xl), xc, (right, *xr)))
1488
+ xyy[:, 0] = (xyy[:, 0] - left) / (right - left)
1489
+
1490
+ # Retain the corresponding gamma *segments*
1491
+ segmentdata[key] = xyy
1492
+ if key == "saturation":
1493
+ ikey = "gamma1"
1494
+ elif key == "luminance":
1495
+ ikey = "gamma2"
1496
+ else:
1497
+ continue
1498
+ if ikey in kwargs:
1499
+ continue
1500
+ gamma = getattr(self, "_" + ikey)
1501
+ if np.iterable(gamma):
1502
+ if callable(xyy):
1503
+ if any(igamma != gamma[0] for igamma in gamma[1:]):
1504
+ warnings._warn_ultraplot(
1505
+ "Cannot use multiple segment gammas when "
1506
+ "truncating colormap. Using the first gamma "
1507
+ f"of {gamma[0]}."
1508
+ )
1509
+ gamma = gamma[0]
1510
+ else:
1511
+ igamma = gamma[l - 1 : r + 1]
1512
+ if len(igamma) == 0: # TODO: issue warning?
1513
+ gamma = gamma[0]
1514
+ else:
1515
+ gamma = igamma
1516
+ kwargs[ikey] = gamma
1517
+
1518
+ return self.copy(name, segmentdata, **kwargs)
1519
+
1520
+ def copy(
1521
+ self,
1522
+ name=None,
1523
+ segmentdata=None,
1524
+ N=None,
1525
+ *,
1526
+ alpha=None,
1527
+ gamma=None,
1528
+ cyclic=None,
1529
+ ):
1530
+ """
1531
+ Return a new colormap with relevant properties copied from this one
1532
+ if they were not provided as keyword arguments.
1533
+
1534
+ Parameters
1535
+ ----------
1536
+ name : str, default: '_name_copy'
1537
+ The new colormap name.
1538
+ segmentdata, N, alpha, gamma, cyclic : optional
1539
+ See `ContinuousColormap`. If not provided, these are copied
1540
+ from the current colormap.
1541
+
1542
+ See also
1543
+ --------
1544
+ DiscreteColormap.copy
1545
+ PerceptualColormap.copy
1546
+ """
1547
+ if name is None:
1548
+ name = self._make_name()
1549
+ if segmentdata is None:
1550
+ segmentdata = self._segmentdata.copy()
1551
+ if gamma is None:
1552
+ gamma = self._gamma
1553
+ if cyclic is None:
1554
+ cyclic = self._cyclic
1555
+ if N is None:
1556
+ N = self.N
1557
+ cmap = ContinuousColormap(
1558
+ name, segmentdata, N, alpha=alpha, gamma=gamma, cyclic=cyclic
1559
+ )
1560
+ cmap._rgba_bad = self._rgba_bad
1561
+ cmap._rgba_under = self._rgba_under
1562
+ cmap._rgba_over = self._rgba_over
1563
+ return cmap
1564
+
1565
+ def to_discrete(self, samples=10, name=None, **kwargs):
1566
+ """
1567
+ Convert the `ContinuousColormap` to a `DiscreteColormap` by drawing
1568
+ samples from the colormap.
1569
+
1570
+ Parameters
1571
+ ----------
1572
+ samples : int or sequence of float, optional
1573
+ If integer, draw samples at the colormap coordinates
1574
+ ``np.linspace(0, 1, samples)``. If sequence of float,
1575
+ draw samples at the specified points.
1576
+ name : str, default: '_name_copy'
1577
+ The new colormap name.
1578
+
1579
+ Other parameters
1580
+ ----------------
1581
+ **kwargs
1582
+ Passed to `DiscreteColormap`.
1583
+
1584
+ See also
1585
+ --------
1586
+ PerceptualColormap.to_continuous
1587
+ """
1588
+ if isinstance(samples, Integral):
1589
+ samples = np.linspace(0, 1, samples)
1590
+ elif not np.iterable(samples):
1591
+ raise TypeError("Samples must be integer or iterable.")
1592
+ samples = np.asarray(samples)
1593
+ colors = self(samples)
1594
+ if name is None:
1595
+ name = self._make_name()
1596
+ return DiscreteColormap(colors, name=name, **kwargs)
1597
+
1598
+ @classmethod
1599
+ @docstring._snippet_manager
1600
+ def from_file(cls, path, *, warn_on_failure=False):
1601
+ """
1602
+ Load colormap from a file.
1603
+
1604
+ Parameters
1605
+ ----------
1606
+ path : path-like
1607
+ The file path. Valid file extensions are shown in the below table.
1608
+
1609
+ %(rc.cmap_exts)s
1610
+
1611
+ warn_on_failure : bool, optional
1612
+ If ``True``, issue a warning when loading fails instead of
1613
+ raising an error.
1614
+
1615
+ See also
1616
+ --------
1617
+ DiscreteColormap.from_file
1618
+ """
1619
+ return cls._from_file(path, warn_on_failure=warn_on_failure)
1620
+
1621
+ @classmethod
1622
+ @docstring._snippet_manager
1623
+ def from_list(cls, *args, **kwargs):
1624
+ """
1625
+ Make a `ContinuousColormap` from a sequence of colors.
1626
+
1627
+ Parameters
1628
+ ----------
1629
+ %(colors.from_list)s
1630
+
1631
+ Other parameters
1632
+ ----------------
1633
+ **kwargs
1634
+ Passed to `ContinuousColormap`.
1635
+
1636
+ Returns
1637
+ -------
1638
+ ContinuousColormap
1639
+ The colormap.
1640
+
1641
+ See also
1642
+ --------
1643
+ matplotlib.colors.LinearSegmentedColormap.from_list
1644
+ PerceptualColormap.from_list
1645
+ """
1646
+ # Get coordinates
1647
+ name, colors, ratios, kwargs = cls._pop_args(
1648
+ *args, names=("colors", "ratios"), **kwargs
1649
+ )
1650
+ coords = None
1651
+ if not np.iterable(colors):
1652
+ raise TypeError("Colors must be iterable.")
1653
+ if (
1654
+ np.iterable(colors[0])
1655
+ and len(colors[0]) == 2
1656
+ and not isinstance(colors[0], str)
1657
+ ):
1658
+ coords, colors = zip(*colors)
1659
+ colors = [to_rgba(color) for color in colors]
1660
+
1661
+ # Build segmentdata
1662
+ keys = ("red", "green", "blue", "alpha")
1663
+ cdict = {}
1664
+ for key, values in zip(keys, zip(*colors)):
1665
+ cdict[key] = _make_segment_data(values, coords, ratios)
1666
+ return cls(name, cdict, **kwargs)
1667
+
1668
+ # Deprecated
1669
+ to_listed = warnings._rename_objs("0.8.0", to_listed=to_discrete)
1670
+ concatenate, punched, truncated, updated = warnings._rename_objs(
1671
+ "0.6.0",
1672
+ concatenate=append,
1673
+ punched=cut,
1674
+ truncated=truncate,
1675
+ updated=copy,
1676
+ )
1677
+
1678
+
1679
+ class DiscreteColormap(mcolors.ListedColormap, _Colormap):
1680
+ r"""
1681
+ Replacement for `~matplotlib.colors.ListedColormap`.
1682
+ """
1683
+
1684
+ def __str__(self):
1685
+ return f"DiscreteColormap(name={self.name!r})"
1686
+
1687
+ def __repr__(self):
1688
+ colors = [c if isinstance(c, str) else to_hex(c) for c in self.colors]
1689
+ string = "DiscreteColormap({\n"
1690
+ string += f" 'name': {self.name!r},\n"
1691
+ string += f" 'colors': {colors!r},\n"
1692
+ string += "})"
1693
+ return string
1694
+
1695
+ def __init__(self, colors, name=None, N=None, alpha=None, **kwargs):
1696
+ """
1697
+ Parameters
1698
+ ----------
1699
+ colors : sequence of color-spec, optional
1700
+ The colormap colors.
1701
+ name : str, default: '_no_name'
1702
+ The colormap name.
1703
+ N : int, default: ``len(colors)``
1704
+ The number of levels. The color list is truncated or wrapped
1705
+ to match this length.
1706
+ alpha : float, optional
1707
+ The opacity for the colormap colors. This overrides the
1708
+ input color opacities.
1709
+
1710
+ Other parameters
1711
+ ----------------
1712
+ **kwargs
1713
+ Passed to `~matplotlib.colors.ListedColormap`.
1714
+
1715
+ See also
1716
+ --------
1717
+ ContinuousColormap
1718
+ matplotlib.colors.ListedColormap
1719
+ ultraplot.constructor.Colormap
1720
+ """
1721
+ # NOTE: This also improves 'monochrome' detection to test all items
1722
+ # in the list. Otherwise ContourSet does not apply negative_linestyle
1723
+ # to monochromatic colormaps generated by passing a 'colors' keyword.
1724
+ # Also note that under the hood, just like ultraplot, ContourSet builds
1725
+ # identical monochromatic ListedColormaps when it receives scalar colors.
1726
+ N = _not_none(N, len(colors))
1727
+ name = _not_none(name, DEFAULT_NAME)
1728
+ super().__init__(colors, name=name, N=N, **kwargs)
1729
+ if alpha is not None:
1730
+ self.set_alpha(alpha)
1731
+ for i, color in enumerate(self.colors):
1732
+ if isinstance(color, np.ndarray):
1733
+ self.colors[i] = color.tolist()
1734
+ if self.colors and all(self.colors[0] == color for color in self.colors):
1735
+ self.monochrome = True # for contour negative dash style
1736
+
1737
+ def append(self, *args, name=None, N=None, **kwargs):
1738
+ """
1739
+ Append arbitrary colormaps onto this colormap.
1740
+
1741
+ Parameters
1742
+ ----------
1743
+ *args
1744
+ Instances of `DiscreteColormap`.
1745
+ name : str, optional
1746
+ The new colormap name. Default is to merge each name with underscores and
1747
+ prepend a leading underscore, for example ``_name1_name2``.
1748
+ N : int, optional
1749
+ The number of points in the colormap lookup table. Default is
1750
+ the number of colors in the concatenated lists.
1751
+
1752
+ Other parameters
1753
+ ----------------
1754
+ **kwargs
1755
+ Passed to `~DiscreteColormap.copy`.
1756
+
1757
+ See also
1758
+ --------
1759
+ ContinuousColormap.append
1760
+ """
1761
+ if not args:
1762
+ return self
1763
+ if not all(isinstance(cmap, mcolors.ListedColormap) for cmap in args):
1764
+ raise TypeError(f"Arguments {args!r} must be DiscreteColormap.")
1765
+ cmaps = (self, *args)
1766
+ if name is None:
1767
+ name = "_" + "_".join(cmap.name for cmap in cmaps)
1768
+ colors = [color for cmap in cmaps for color in cmap.colors]
1769
+ N = _not_none(N, len(colors))
1770
+ return self.copy(colors, name, N, **kwargs)
1771
+
1772
+ @docstring._snippet_manager
1773
+ def save(self, path=None, alpha=True):
1774
+ """
1775
+ Save the colormap data to a file.
1776
+
1777
+ Parameters
1778
+ ----------
1779
+ path : path-like, optional
1780
+ The output filename. If not provided, the colormap is saved in the
1781
+ ``cycles`` subfolder in `~ultraplot.config.Configurator.user_folder`
1782
+ under the filename ``name.hex`` (where ``name`` is the color cycle
1783
+ name). Valid extensions are described in the below table.
1784
+
1785
+ %(rc.cycle_exts)s
1786
+
1787
+ alpha : bool, optional
1788
+ Whether to include an opacity column for ``.rgb``
1789
+ and ``.txt`` files.
1790
+
1791
+ See also
1792
+ --------
1793
+ ContinuousColormap.save
1794
+ """
1795
+ filename = self._parse_path(path, ext="hex", subfolder="cycles")
1796
+ _, ext = os.path.splitext(filename)
1797
+ data = self._get_data(ext[1:], alpha=alpha)
1798
+ with open(filename, "w") as fh:
1799
+ fh.write(data)
1800
+ print(f"Saved colormap to {filename!r}.")
1801
+
1802
+ def set_alpha(self, alpha):
1803
+ """
1804
+ Set the opacity for the entire colormap.
1805
+
1806
+ Parameters
1807
+ ----------
1808
+ alpha : float
1809
+ The opacity.
1810
+
1811
+ See also
1812
+ --------
1813
+ ContinuousColormap.set_alpha
1814
+ """
1815
+ self.colors = [set_alpha(color, alpha) for color in self.colors]
1816
+ self._init()
1817
+
1818
+ def reversed(self, name=None, **kwargs):
1819
+ """
1820
+ Return a reversed version of the colormap.
1821
+
1822
+ Parameters
1823
+ ----------
1824
+ name : str, default: '_name_r'
1825
+ The new colormap name.
1826
+
1827
+ Other parameters
1828
+ ----------------
1829
+ **kwargs
1830
+ Passed to `DiscreteColormap.copy`
1831
+
1832
+ See also
1833
+ --------
1834
+ matplotlib.colors.ListedColormap.reversed
1835
+ """
1836
+ if name is None:
1837
+ name = self._make_name(suffix="r")
1838
+ colors = self.colors[::-1]
1839
+ cmap = self.copy(colors, name, **kwargs)
1840
+ cmap._rgba_under, cmap._rgba_over = cmap._rgba_over, cmap._rgba_under
1841
+ return cmap
1842
+
1843
+ def shifted(self, shift=1, name=None):
1844
+ """
1845
+ Return a cyclically shifted version of the colormap.
1846
+
1847
+ Parameters
1848
+ ----------
1849
+ shift : float, default: 1
1850
+ The number of list indices to shift.
1851
+ name : str, eefault: '_name_s'
1852
+ The new colormap name.
1853
+
1854
+ See also
1855
+ --------
1856
+ ContinuousColormap.shifted
1857
+ """
1858
+ if not shift:
1859
+ return self
1860
+ if name is None:
1861
+ name = self._make_name(suffix="s")
1862
+ shift = shift % len(self.colors)
1863
+ colors = list(self.colors)
1864
+ colors = colors[shift:] + colors[:shift]
1865
+ return self.copy(colors, name, len(colors))
1866
+
1867
+ def truncate(self, left=None, right=None, name=None):
1868
+ """
1869
+ Return a truncated version of the colormap.
1870
+
1871
+ Parameters
1872
+ ----------
1873
+ left : float, default: None
1874
+ The colormap index for the new "leftmost" color. Must fall between ``0``
1875
+ and ``self.N``. For example, ``left=2`` drops the first two colors.
1876
+ right : float, default: None
1877
+ The colormap index for the new "rightmost" color. Must fall between ``0``
1878
+ and ``self.N``. For example, ``right=4`` keeps the first four colors.
1879
+ name : str, default: '_name_copy'
1880
+ The new colormap name.
1881
+
1882
+ See also
1883
+ --------
1884
+ ContinuousColormap.truncate
1885
+ """
1886
+ if left is None and right is None:
1887
+ return self
1888
+ if name is None:
1889
+ name = self._make_name()
1890
+ colors = self.colors[left:right]
1891
+ return self.copy(colors, name, len(colors))
1892
+
1893
+ def copy(self, colors=None, name=None, N=None, *, alpha=None):
1894
+ """
1895
+ Return a new colormap with relevant properties copied from this one
1896
+ if they were not provided as keyword arguments.
1897
+
1898
+ Parameters
1899
+ ----------
1900
+ name : str, default: '_name_copy'
1901
+ The new colormap name.
1902
+ colors, N, alpha : optional
1903
+ See `DiscreteColormap`. If not provided,
1904
+ these are copied from the current colormap.
1905
+
1906
+ See also
1907
+ --------
1908
+ ContinuousColormap.copy
1909
+ PerceptualColormap.copy
1910
+ """
1911
+ if name is None:
1912
+ name = self._make_name()
1913
+ if colors is None:
1914
+ colors = list(self.colors) # copy
1915
+ if N is None:
1916
+ N = self.N
1917
+ cmap = DiscreteColormap(colors, name, N=N, alpha=alpha)
1918
+ cmap._rgba_bad = self._rgba_bad
1919
+ cmap._rgba_under = self._rgba_under
1920
+ cmap._rgba_over = self._rgba_over
1921
+ return cmap
1922
+
1923
+ @classmethod
1924
+ @docstring._snippet_manager
1925
+ def from_file(cls, path, *, warn_on_failure=False):
1926
+ """
1927
+ Load color cycle from a file.
1928
+
1929
+ Parameters
1930
+ ----------
1931
+ path : path-like
1932
+ The file path. Valid file extensions are shown in the below table.
1933
+
1934
+ %(rc.cycle_exts)s
1935
+
1936
+ warn_on_failure : bool, optional
1937
+ If ``True``, issue a warning when loading fails instead of
1938
+ raising an error.
1939
+
1940
+ See also
1941
+ --------
1942
+ ContinuousColormap.from_file
1943
+ """
1944
+ return cls._from_file(path, warn_on_failure=warn_on_failure)
1945
+
1946
+ # Rename methods
1947
+ concatenate, truncated, updated = warnings._rename_objs(
1948
+ "0.6.0",
1949
+ concatenate=append,
1950
+ truncated=truncate,
1951
+ updated=copy,
1952
+ )
1953
+
1954
+
1955
+ class PerceptualColormap(ContinuousColormap):
1956
+ """
1957
+ A `ContinuousColormap` with linear transitions across hue, saturation,
1958
+ and luminance rather than red, blue, and green.
1959
+ """
1960
+
1961
+ @docstring._snippet_manager
1962
+ def __init__(
1963
+ self,
1964
+ *args,
1965
+ space=None,
1966
+ clip=True,
1967
+ gamma=None,
1968
+ gamma1=None,
1969
+ gamma2=None,
1970
+ **kwargs,
1971
+ ):
1972
+ """
1973
+ Parameters
1974
+ ----------
1975
+ segmentdata : dict-like
1976
+ Dictionary containing the keys ``'hue'``, ``'saturation'``,
1977
+ ``'luminance'``, and (optionally) ``'alpha'``. The key ``'chroma'`` is
1978
+ treated as a synonym for ``'saturation'``. The shorthands ``'h'``,
1979
+ ``'s'``, ``'l'``, ``'a'``, and ``'c'`` are also acceptable. The key
1980
+ values can be callable functions that return channel values given a
1981
+ colormap index, or 3-column arrays indicating the coordinates and
1982
+ channel transitions. See `~matplotlib.colors.LinearSegmentedColormap`
1983
+ for a more detailed explanation.
1984
+ %(colors.name)s
1985
+ %(colors.N)s
1986
+ %(colors.space)s
1987
+ clip : bool, optional
1988
+ Whether to "clip" impossible colors (i.e. truncate HCL colors with
1989
+ RGB channels with values greater than 1) or mask them out as gray.
1990
+ %(colors.gamma)s
1991
+ %(colors.alpha)s
1992
+ %(colors.cyclic)s
1993
+
1994
+ Other parameters
1995
+ ----------------
1996
+ **kwargs
1997
+ Passed to `matploitlib.colors.LinearSegmentedColormap`.
1998
+
1999
+ Example
2000
+ -------
2001
+ The below example generates a `PerceptualColormap` from a
2002
+ `segmentdata` dictionary that uses color names for the hue data,
2003
+ instead of channel values between ``0`` and ``360``.
2004
+
2005
+ >>> import ultraplot as pplt
2006
+ >>> data = {
2007
+ >>> 'h': [[0, 'red', 'red'], [1, 'blue', 'blue']],
2008
+ >>> 's': [[0, 100, 100], [1, 100, 100]],
2009
+ >>> 'l': [[0, 100, 100], [1, 20, 20]],
2010
+ >>> }
2011
+ >>> cmap = pplt.PerceptualColormap(data)
2012
+
2013
+ See also
2014
+ --------
2015
+ ContinuousColormap
2016
+ ultraplot.constructor.Colormap
2017
+ """
2018
+ # Checks
2019
+ name, segmentdata, N, kwargs = self._pop_args(
2020
+ *args, names=("segmentdata", "N"), **kwargs
2021
+ )
2022
+ data = _pop_props(segmentdata, "hsla")
2023
+ if segmentdata:
2024
+ raise ValueError(f"Invalid segmentdata keys {tuple(segmentdata)}.")
2025
+ space = _not_none(space, DEFAULT_SPACE).lower()
2026
+ if space not in ("rgb", "hsv", "hpl", "hsl", "hcl"):
2027
+ raise ValueError(f"Unknown colorspace {space!r}.")
2028
+ # Convert color strings to channel values
2029
+ for key, array in data.items():
2030
+ if callable(array): # permit callable
2031
+ continue
2032
+ for i, xyy in enumerate(array):
2033
+ xyy = list(xyy) # make copy!
2034
+ for j, y in enumerate(xyy[1:]): # modify the y values
2035
+ xyy[j + 1] = _get_channel(y, key, space)
2036
+ data[key][i] = xyy
2037
+ # Initialize
2038
+ super().__init__(name, data, gamma=1.0, N=N, **kwargs)
2039
+ self._gamma1 = _not_none(gamma1, gamma, 1.0)
2040
+ self._gamma2 = _not_none(gamma2, gamma, 1.0)
2041
+ self._space = space
2042
+ self._clip = clip
2043
+
2044
+ def _init(self):
2045
+ """
2046
+ As with `~matplotlib.colors.LinearSegmentedColormap`, but convert
2047
+ each value in the lookup table from ``self._space`` to RGB.
2048
+ """
2049
+ # First generate the lookup table
2050
+ channels = ("hue", "saturation", "luminance")
2051
+ inverses = (False, False, True) # weight low chroma, high luminance
2052
+ gammas = (1.0, self._gamma1, self._gamma2)
2053
+ self._lut_hsl = np.ones((self.N + 3, 4), float) # fill
2054
+ for i, (channel, gamma, inverse) in enumerate(zip(channels, gammas, inverses)):
2055
+ self._lut_hsl[:-3, i] = _make_lookup_table(
2056
+ self.N, self._segmentdata[channel], gamma, inverse
2057
+ )
2058
+ if "alpha" in self._segmentdata:
2059
+ self._lut_hsl[:-3, 3] = _make_lookup_table(
2060
+ self.N, self._segmentdata["alpha"]
2061
+ )
2062
+ self._lut_hsl[:-3, 0] %= 360
2063
+
2064
+ # Make hues circular, set extremes i.e. copy HSL values
2065
+ self._lut = self._lut_hsl.copy()
2066
+ self._set_extremes() # generally just used end values in segmentdata
2067
+ self._isinit = True
2068
+
2069
+ # Now convert values to RGB and clip colors
2070
+ for i in range(self.N + 3):
2071
+ self._lut[i, :3] = to_rgb(self._lut[i, :3], self._space)
2072
+ self._lut[:, :3] = _clip_colors(self._lut[:, :3], self._clip)
2073
+
2074
+ @docstring._snippet_manager
2075
+ def set_gamma(self, gamma=None, gamma1=None, gamma2=None):
2076
+ """
2077
+ Set the gamma value(s) for the luminance and saturation transitions.
2078
+
2079
+ Parameters
2080
+ ----------
2081
+ %(colors.gamma)s
2082
+ """
2083
+ gamma1 = _not_none(gamma1, gamma)
2084
+ gamma2 = _not_none(gamma2, gamma)
2085
+ if gamma1 is not None:
2086
+ self._gamma1 = gamma1
2087
+ if gamma2 is not None:
2088
+ self._gamma2 = gamma2
2089
+ self._init()
2090
+
2091
+ def copy(
2092
+ self,
2093
+ name=None,
2094
+ segmentdata=None,
2095
+ N=None,
2096
+ *,
2097
+ alpha=None,
2098
+ gamma=None,
2099
+ cyclic=None,
2100
+ clip=None,
2101
+ gamma1=None,
2102
+ gamma2=None,
2103
+ space=None,
2104
+ ):
2105
+ """
2106
+ Return a new colormap with relevant properties copied from this one
2107
+ if they were not provided as keyword arguments.
2108
+
2109
+ Parameters
2110
+ ----------
2111
+ name : str, default: '_name_copy'
2112
+ The new colormap name.
2113
+ segmentdata, N, alpha, clip, cyclic, gamma, gamma1, gamma2, space : optional
2114
+ See `PerceptualColormap`. If not provided,
2115
+ these are copied from the current colormap.
2116
+
2117
+ See also
2118
+ --------
2119
+ DiscreteColormap.copy
2120
+ ContinuousColormap.copy
2121
+ """
2122
+ if name is None:
2123
+ name = self._make_name()
2124
+ if segmentdata is None:
2125
+ segmentdata = self._segmentdata.copy()
2126
+ if space is None:
2127
+ space = self._space
2128
+ if clip is None:
2129
+ clip = self._clip
2130
+ if gamma is not None:
2131
+ gamma1 = gamma2 = gamma
2132
+ if gamma1 is None:
2133
+ gamma1 = self._gamma1
2134
+ if gamma2 is None:
2135
+ gamma2 = self._gamma2
2136
+ if cyclic is None:
2137
+ cyclic = self._cyclic
2138
+ if N is None:
2139
+ N = self.N
2140
+ cmap = PerceptualColormap(
2141
+ name,
2142
+ segmentdata,
2143
+ N,
2144
+ alpha=alpha,
2145
+ clip=clip,
2146
+ cyclic=cyclic,
2147
+ gamma1=gamma1,
2148
+ gamma2=gamma2,
2149
+ space=space,
2150
+ )
2151
+ cmap._rgba_bad = self._rgba_bad
2152
+ cmap._rgba_under = self._rgba_under
2153
+ cmap._rgba_over = self._rgba_over
2154
+ return cmap
2155
+
2156
+ def to_continuous(self, name=None, **kwargs):
2157
+ """
2158
+ Convert the `PerceptualColormap` to a standard `ContinuousColormap`.
2159
+ This is used to merge such colormaps.
2160
+
2161
+ Parameters
2162
+ ----------
2163
+ name : str, default: '_name_copy'
2164
+ The new colormap name.
2165
+
2166
+ Other parameters
2167
+ ----------------
2168
+ **kwargs
2169
+ Passed to `ContinuousColormap`.
2170
+
2171
+ See also
2172
+ --------
2173
+ ContinuousColormap.to_discrete
2174
+ """
2175
+ if not self._isinit:
2176
+ self._init()
2177
+ if name is None:
2178
+ name = self._make_name()
2179
+ return ContinuousColormap.from_list(name, self._lut[:-3, :], **kwargs)
2180
+
2181
+ @classmethod
2182
+ @docstring._snippet_manager
2183
+ @warnings._rename_kwargs("0.7.0", fade="saturation", shade="luminance")
2184
+ def from_color(cls, *args, **kwargs):
2185
+ """
2186
+ Return a simple monochromatic "sequential" colormap that blends from white
2187
+ or near-white to the input color.
2188
+
2189
+ Parameters
2190
+ ----------
2191
+ color : color-spec
2192
+ RGB tuple, hex string, or named color string.
2193
+ %(colors.name)s
2194
+ %(colors.space)s
2195
+ l, s, a, c
2196
+ Shorthands for `luminance`, `saturation`, `alpha`, and `chroma`.
2197
+ luminance : float or color-spec, default: 100
2198
+ If float, this is the luminance channel strength on the left-hand
2199
+ side of the colormap. If RGB[A] tuple, hex string, or named color
2200
+ string, the luminance is inferred from the color.
2201
+ saturation, alpha : float or color-spec, optional
2202
+ As with `luminance`, except the default `saturation` and the default
2203
+ `alpha` are the channel values taken from `color`.
2204
+ chroma
2205
+ Alias for `saturation`.
2206
+
2207
+ Other parameters
2208
+ ----------------
2209
+ **kwargs
2210
+ Passed to `PerceptualColormap.from_hsl`.
2211
+
2212
+ Returns
2213
+ -------
2214
+ PerceptualColormap
2215
+ The colormap.
2216
+
2217
+ See also
2218
+ --------
2219
+ PerceptualColormap.from_hsl
2220
+ PerceptualColormap.from_list
2221
+ """
2222
+ name, color, space, kwargs = cls._pop_args(
2223
+ *args, names=("color", "space"), **kwargs
2224
+ )
2225
+ space = _not_none(space, DEFAULT_SPACE).lower()
2226
+ props = _pop_props(kwargs, "hsla")
2227
+ if props.get("hue", None) is not None:
2228
+ raise TypeError("from_color() got an unexpected keyword argument 'hue'")
2229
+ hue, saturation, luminance, alpha = to_xyza(color, space)
2230
+ alpha_fade = props.pop("alpha", 1)
2231
+ luminance_fade = props.pop("luminance", 100)
2232
+ saturation_fade = props.pop("saturation", saturation)
2233
+ return cls.from_hsl(
2234
+ name,
2235
+ hue=hue,
2236
+ space=space,
2237
+ alpha=(alpha_fade, alpha),
2238
+ saturation=(saturation_fade, saturation),
2239
+ luminance=(luminance_fade, luminance),
2240
+ **kwargs,
2241
+ )
2242
+
2243
+ @classmethod
2244
+ @docstring._snippet_manager
2245
+ def from_hsl(cls, *args, **kwargs):
2246
+ """
2247
+ Make a `~PerceptualColormap` by specifying the hue,
2248
+ saturation, and luminance transitions individually.
2249
+
2250
+ Parameters
2251
+ ----------
2252
+ %(colors.space)s
2253
+ %(colors.name)s
2254
+ %(colors.ratios)s
2255
+ For example, ``luminance=(100, 50, 0)`` with ``ratios=(2, 1)`` results
2256
+ in a colormap with the transition from luminance ``100`` to ``50`` taking
2257
+ *twice as long* as the transition from luminance ``50`` to ``0``.
2258
+ h, s, l, a, c
2259
+ Shorthands for `hue`, `saturation`, `luminance`, `alpha`, and `chroma`.
2260
+ hue : float or color-spec or sequence, default: 0
2261
+ Hue channel value or sequence of values. The shorthand keyword `h` is also
2262
+ acceptable. Values can be any of the following.
2263
+
2264
+ 1. Numbers, within the range 0 to 360 for hue and 0 to 100 for
2265
+ saturation and luminance.
2266
+ 2. Color string names or hex strings, in which case the channel
2267
+ value for that color is looked up.
2268
+ saturation : float or color-spec or sequence, default: 50
2269
+ As with `hue`, but for the saturation channel.
2270
+ luminance : float or color-spec or sequence, default: ``(100, 20)``
2271
+ As with `hue`, but for the luminance channel.
2272
+ alpha : float or color-spec or sequence, default: 1
2273
+ As with `hue`, but for the alpha (opacity) channel.
2274
+ chroma
2275
+ Alias for `saturation`.
2276
+
2277
+ Other parameters
2278
+ ----------------
2279
+ **kwargs
2280
+ Passed to `PerceptualColormap`.
2281
+
2282
+ Returns
2283
+ -------
2284
+ PerceptualColormap
2285
+ The colormap.
2286
+
2287
+ See also
2288
+ --------
2289
+ PerceptualColormap.from_color
2290
+ PerceptualColormap.from_list
2291
+ """
2292
+ name, space, ratios, kwargs = cls._pop_args(
2293
+ *args, names=("space", "ratios"), **kwargs
2294
+ )
2295
+ cdict = {}
2296
+ props = _pop_props(kwargs, "hsla")
2297
+ for key, default in (
2298
+ ("hue", 0),
2299
+ ("saturation", 100),
2300
+ ("luminance", (100, 20)),
2301
+ ("alpha", 1),
2302
+ ):
2303
+ value = props.pop(key, default)
2304
+ cdict[key] = _make_segment_data(value, ratios=ratios)
2305
+ return cls(name, cdict, space=space, **kwargs)
2306
+
2307
+ @classmethod
2308
+ @docstring._snippet_manager
2309
+ def from_list(cls, *args, adjust_grays=True, **kwargs):
2310
+ """
2311
+ Make a `PerceptualColormap` from a sequence of colors.
2312
+
2313
+ Parameters
2314
+ ----------
2315
+ %(colors.from_list)s
2316
+ adjust_grays : bool, optional
2317
+ Whether to adjust the hues of grayscale colors (including ``'white'``,
2318
+ ``'black'``, and the ``'grayN'`` open-color colors) to the hues of the
2319
+ preceding and subsequent colors in the sequence. This facilitates the
2320
+ construction of diverging colormaps with monochromatic segments using
2321
+ e.g. ``PerceptualColormap.from_list(['blue', 'white', 'red'])``.
2322
+
2323
+ Other parameters
2324
+ ----------------
2325
+ **kwargs
2326
+ Passed to `PerceptualColormap`.
2327
+
2328
+ Returns
2329
+ -------
2330
+ PerceptualColormap
2331
+ The colormap.
2332
+
2333
+ See also
2334
+ --------
2335
+ matplotlib.colors.LinearSegmentedColormap.from_list
2336
+ ContinuousColormap.from_list
2337
+ PerceptualColormap.from_color
2338
+ PerceptualColormap.from_hsl
2339
+ """
2340
+ # Get coordinates
2341
+ coords = None
2342
+ space = kwargs.get("space", DEFAULT_SPACE).lower()
2343
+ name, colors, ratios, kwargs = cls._pop_args(
2344
+ *args, names=("colors", "ratios"), **kwargs
2345
+ )
2346
+ if not np.iterable(colors):
2347
+ raise ValueError(f"Colors must be iterable, got colors={colors!r}")
2348
+ if (
2349
+ np.iterable(colors[0])
2350
+ and len(colors[0]) == 2
2351
+ and not isinstance(colors[0], str)
2352
+ ):
2353
+ coords, colors = zip(*colors)
2354
+
2355
+ # Build segmentdata
2356
+ keys = ("hue", "saturation", "luminance", "alpha")
2357
+ hslas = [to_xyza(color, space) for color in colors]
2358
+ cdict = {}
2359
+ for key, values in zip(keys, zip(*hslas)):
2360
+ cdict[key] = _make_segment_data(values, coords, ratios)
2361
+
2362
+ # Adjust grays
2363
+ if adjust_grays:
2364
+ hues = cdict["hue"] # segment data
2365
+ for i, color in enumerate(colors):
2366
+ rgb = to_rgb(color)
2367
+ if isinstance(color, str) and REGEX_ADJUST.match(color):
2368
+ pass
2369
+ elif not np.allclose(np.array(rgb), rgb[0]):
2370
+ continue
2371
+ hues[i] = list(hues[i]) # enforce mutability
2372
+ if i > 0:
2373
+ hues[i][1] = hues[i - 1][2]
2374
+ if i < len(hues) - 1:
2375
+ hues[i][2] = hues[i + 1][1]
2376
+
2377
+ return cls(name, cdict, **kwargs)
2378
+
2379
+ # Deprecated
2380
+ to_linear_segmented = warnings._rename_objs(
2381
+ "0.8.0", to_linear_segmented=to_continuous
2382
+ )
2383
+
2384
+
2385
+ def _interpolate_scalar(x, x0, x1, y0, y1):
2386
+ """
2387
+ Interpolate between two points.
2388
+ """
2389
+ return y0 + (y1 - y0) * (x - x0) / (x1 - x0)
2390
+
2391
+
2392
+ def _interpolate_extrapolate_vector(xq, x, y):
2393
+ """
2394
+ Interpolate between two vectors. Similar to `numpy.interp` except this
2395
+ does not truncate out-of-bounds values (i.e. this is reversible).
2396
+ """
2397
+ # Follow example of _make_lookup_table for efficient, vectorized
2398
+ # linear interpolation across multiple segments.
2399
+ # * Normal test puts values at a[i] if a[i-1] < v <= a[i]; for
2400
+ # left-most data, satisfy a[0] <= v <= a[1]
2401
+ # * searchsorted gives where xq[i] must be inserted so it is larger
2402
+ # than x[ind[i]-1] but smaller than x[ind[i]]
2403
+ # yq = ma.masked_array(np.interp(xq, x, y), mask=ma.getmask(xq))
2404
+ x = np.asarray(x)
2405
+ y = np.asarray(y)
2406
+ xq = np.atleast_1d(xq)
2407
+ idx = np.searchsorted(x, xq)
2408
+ idx[idx == 0] = 1 # get normed value <0
2409
+ idx[idx == len(x)] = len(x) - 1 # get normed value >0
2410
+ distance = (xq - x[idx - 1]) / (x[idx] - x[idx - 1])
2411
+ yq = distance * (y[idx] - y[idx - 1]) + y[idx - 1]
2412
+ yq = ma.masked_array(yq, mask=ma.getmask(xq))
2413
+ return yq
2414
+
2415
+
2416
+ def _sanitize_levels(levels, minsize=2):
2417
+ """
2418
+ Ensure the levels are monotonic. If they are descending, reverse them.
2419
+ """
2420
+ # NOTE: Matplotlib does not support datetime colormap levels as of 3.5
2421
+ levels = inputs._to_numpy_array(levels)
2422
+ if levels.ndim != 1 or levels.size < minsize:
2423
+ raise ValueError(f"Levels {levels} must be a 1D array with size >= {minsize}.")
2424
+ if isinstance(levels, ma.core.MaskedArray):
2425
+ levels = levels.filled(np.nan)
2426
+ if not inputs._is_numeric(levels) or not np.all(np.isfinite(levels)):
2427
+ raise ValueError(f"Levels {levels} does not support non-numeric cmap levels.")
2428
+ diffs = np.sign(np.diff(levels))
2429
+ if np.all(diffs == 1):
2430
+ descending = False
2431
+ elif np.all(diffs == -1):
2432
+ descending = True
2433
+ levels = levels[::-1]
2434
+ else:
2435
+ raise ValueError(f"Levels {levels} must be monotonic.")
2436
+ return levels, descending
2437
+
2438
+
2439
+ class DiscreteNorm(mcolors.BoundaryNorm):
2440
+ """
2441
+ Meta-normalizer that discretizes the possible color values returned by
2442
+ arbitrary continuous normalizers given a sequence of level boundaries.
2443
+ """
2444
+
2445
+ # See this post: https://stackoverflow.com/a/48614231/4970632
2446
+ # WARNING: Must be child of BoundaryNorm. Many methods in ColorBarBase
2447
+ # test for class membership, crucially including _process_values(), which
2448
+ # if it doesn't detect BoundaryNorm will try to use DiscreteNorm.inverse().
2449
+ @warnings._rename_kwargs(
2450
+ "0.7.0", extend="unique", descending="DiscreteNorm(descending_levels)"
2451
+ )
2452
+ def __init__(
2453
+ self,
2454
+ levels,
2455
+ norm=None,
2456
+ unique=None,
2457
+ step=None,
2458
+ clip=False,
2459
+ ticks=None,
2460
+ labels=None,
2461
+ ):
2462
+ """
2463
+ Parameters
2464
+ ----------
2465
+ levels : sequence of float
2466
+ The level boundaries. Must be monotonically increasing or decreasing.
2467
+ If the latter then `~DiscreteNorm.descending` is set to ``True`` and the
2468
+ colorbar axis drawn with this normalizer will be reversed.
2469
+ norm : `~matplotlib.colors.Normalize`, optional
2470
+ The normalizer used to transform `levels` and data values passed to
2471
+ `~DiscreteNorm.__call__` before discretization. The ``vmin`` and ``vmax``
2472
+ of the normalizer are set to the minimum and maximum values in `levels`.
2473
+ unique : {'neither', 'both', 'min', 'max'}, optional
2474
+ Which out-of-bounds regions should be assigned unique colormap colors.
2475
+ Possible values are equivalent to the `extend` values. Internally, ultraplot
2476
+ sets this depending on the user-input `extend`, whether the colormap is
2477
+ cyclic, and whether `~matplotlib.colors.Colormap.set_under`
2478
+ or `~matplotlib.colors.Colormap.set_over` were called for the colormap.
2479
+ step : float, optional
2480
+ The intensity of the transition to out-of-bounds colors as a fraction
2481
+ of the adjacent step between in-bounds colors. Internally, ultraplot sets
2482
+ this to ``0.5`` for cyclic colormaps and ``1`` for all other colormaps.
2483
+ This only has an effect on lower colors when `unique` is ``'min'`` or
2484
+ ``'both'``, and on upper colors when `unique` is ``'max'`` or ``'both'``.
2485
+ clip : bool, optional
2486
+ Whether to clip values falling outside of the level bins. This only
2487
+ has an effect on lower colors when `unique` is ``'min'`` or ``'both'``,
2488
+ and on upper colors when `unique` is ``'max'`` or ``'both'``.
2489
+
2490
+ Other parameters
2491
+ ----------------
2492
+ ticks : array-like, default: `levels`
2493
+ Default tick values to use for colorbars drawn with this normalizer. This
2494
+ is set to the level centers when `values` is passed to a plotting command.
2495
+ labels : array-like, optional
2496
+ Default tick labels to use for colorbars drawn with this normalizer. This
2497
+ is set to values when drawing on-the-fly colorbars.
2498
+
2499
+ Note
2500
+ ----
2501
+ This normalizer makes sure that levels always span the full range of
2502
+ colors in the colormap, whether `extend` is set to ``'min'``, ``'max'``,
2503
+ ``'neither'``, or ``'both'``. In matplotlib, when `extend` is not ``'both'``,
2504
+ the most intense colors are cut off (reserved for "out of bounds" data),
2505
+ even though they are not being used.
2506
+
2507
+ See also
2508
+ --------
2509
+ ultraplot.constructor.Norm
2510
+ ultraplot.colors.SegmentedNorm
2511
+ ultraplot.ticker.DiscreteLocator
2512
+ """
2513
+ # Parse input arguments
2514
+ # NOTE: This must be a subclass BoundaryNorm, so ColorbarBase will
2515
+ # detect it... even though we completely override it.
2516
+ if step is None:
2517
+ step = 1.0
2518
+ if unique is None:
2519
+ unique = "neither"
2520
+ if not norm:
2521
+ norm = mcolors.Normalize()
2522
+ elif isinstance(norm, mcolors.BoundaryNorm):
2523
+ raise ValueError("Normalizer cannot be instance of BoundaryNorm.")
2524
+ elif not isinstance(norm, mcolors.Normalize):
2525
+ raise ValueError("Normalizer must be instance of Normalize.")
2526
+ uniques = ("min", "max", "both", "neither")
2527
+ if unique not in uniques:
2528
+ raise ValueError(
2529
+ f"Unknown unique setting {unique!r}. Options are: "
2530
+ + ", ".join(map(repr, uniques))
2531
+ + "."
2532
+ )
2533
+
2534
+ # Process level boundaries and centers
2535
+ # NOTE: Currently there are no normalizers that reverse direction
2536
+ # of levels. Tried that with SegmentedNorm but colorbar ticks fail.
2537
+ # Instead user-reversed levels will always get passed here just as
2538
+ # they are passed to SegmentedNorm inside plot.py
2539
+ levels, descending = _sanitize_levels(levels)
2540
+ vmin = norm.vmin = np.min(levels)
2541
+ vmax = norm.vmax = np.max(levels)
2542
+ bins, _ = _sanitize_levels(norm(levels))
2543
+ vcenter = getattr(norm, "vcenter", None)
2544
+ mids = np.zeros((levels.size + 1,))
2545
+ mids[1:-1] = 0.5 * (levels[1:] + levels[:-1])
2546
+ mids[0], mids[-1] = mids[1], mids[-2]
2547
+
2548
+ # Adjust color coordinate for each bin
2549
+ # For same out-of-bounds colors, looks like [0 - eps, 0, ..., 1, 1 + eps]
2550
+ # For unique out-of-bounds colors, looks like [0 - eps, X, ..., 1 - X, 1 + eps]
2551
+ # NOTE: Critical that we scale the bin centers in "physical space" and *then*
2552
+ # translate to color coordinates so that nonlinearities in the normalization
2553
+ # stay intact. If we scaled the bin centers in *normalized space* to have
2554
+ # minimum 0 maximum 1, would mess up color distribution. However this is still
2555
+ # not perfect... get asymmetric color intensity either side of central point.
2556
+ # So we add special handling for diverging norms below to improve symmetry.
2557
+ if unique in ("min", "both"):
2558
+ mids[0] += step * (mids[1] - mids[2])
2559
+ if unique in ("max", "both"):
2560
+ mids[-1] += step * (mids[-2] - mids[-3])
2561
+ mmin, mmax = np.min(mids), np.max(mids)
2562
+ if vcenter is None:
2563
+ mids = _interpolate_scalar(mids, mmin, mmax, vmin, vmax)
2564
+ else:
2565
+ mask1, mask2 = mids < vcenter, mids >= vcenter
2566
+ mids[mask1] = _interpolate_scalar(mids[mask1], mmin, vcenter, vmin, vcenter)
2567
+ mids[mask2] = _interpolate_scalar(mids[mask2], vcenter, mmax, vcenter, vmax)
2568
+
2569
+ # Instance attributes
2570
+ # NOTE: If clip is True, we clip values to the centers of the end bins
2571
+ # rather than vmin/vmax to prevent out-of-bounds colors from getting an
2572
+ # in-bounds bin color due to landing on a bin edge.
2573
+ # NOTE: With unique='min' the minimimum in-bounds and out-of-bounds
2574
+ # colors are the same so clip=True will have no effect. Same goes
2575
+ # for unique='max' with maximum colors.
2576
+ eps = 1e-10
2577
+ dest = norm(mids)
2578
+ dest[0] -= eps # dest guaranteed to be numpy.float64
2579
+ dest[-1] += eps
2580
+ self._ticks = _not_none(ticks, levels)
2581
+ self._labels = labels
2582
+ self._descending = descending
2583
+ self._bmin = np.min(mids)
2584
+ self._bmax = np.max(mids)
2585
+ self._bins = bins
2586
+ self._dest = dest
2587
+ self._norm = norm
2588
+ self.N = levels.size
2589
+ self.boundaries = levels
2590
+ mcolors.Normalize.__init__(self, vmin=vmin, vmax=vmax, clip=clip)
2591
+
2592
+ # Add special clipping
2593
+ # WARNING: For some reason must clip manually for LogNorm, or end
2594
+ # up with unpredictable fill value, weird "out-of-bounds" colors
2595
+ self._norm_clip = None
2596
+ if isinstance(norm, mcolors.LogNorm):
2597
+ self._norm_clip = (1e-249, None)
2598
+
2599
+ def __call__(self, value, clip=None):
2600
+ """
2601
+ Normalize data values to 0-1.
2602
+
2603
+ Parameters
2604
+ ----------
2605
+ value : numeric
2606
+ The data to be normalized.
2607
+ clip : bool, default: ``self.clip``
2608
+ Whether to clip values falling outside of the level bins.
2609
+ """
2610
+ # Follow example of SegmentedNorm, but perform no interpolation,
2611
+ # just use searchsorted to bin the data.
2612
+ norm_clip = self._norm_clip
2613
+ if norm_clip: # special extra clipping due to normalizer
2614
+ value = np.clip(value, *norm_clip)
2615
+ if clip is None: # builtin clipping
2616
+ clip = self.clip
2617
+ if clip: # note that np.clip can handle masked arrays
2618
+ value = np.clip(value, self._bmin, self._bmax)
2619
+ xq, is_scalar = self.process_value(value)
2620
+ xq = self._norm(xq)
2621
+ yq = self._dest[np.searchsorted(self._bins, xq)]
2622
+ yq = ma.array(yq, mask=ma.getmask(xq))
2623
+ if is_scalar:
2624
+ yq = np.atleast_1d(yq)[0]
2625
+ if self.descending:
2626
+ yq = 1 - yq
2627
+ return yq
2628
+
2629
+ def inverse(self, value): # noqa: U100
2630
+ """
2631
+ Raise an error.
2632
+
2633
+ Raises
2634
+ ------
2635
+ ValueError
2636
+ Inversion after discretization is impossible.
2637
+ """
2638
+ raise ValueError("DiscreteNorm is not invertible.")
2639
+
2640
+ @property
2641
+ def descending(self):
2642
+ """
2643
+ Boolean indicating whether the levels are descending.
2644
+ """
2645
+ return self._descending
2646
+
2647
+
2648
+ class SegmentedNorm(mcolors.Normalize):
2649
+ """
2650
+ Normalizer that scales data linearly with respect to the
2651
+ interpolated index in an arbitrary monotonic level sequence.
2652
+ """
2653
+
2654
+ def __init__(self, levels, vmin=None, vmax=None, clip=False):
2655
+ """
2656
+ Parameters
2657
+ ----------
2658
+ levels : sequence of float
2659
+ The level boundaries. Must be monotonically increasing
2660
+ or decreasing.
2661
+ vmin : float, optional
2662
+ Ignored but included for consistency with other normalizers.
2663
+ Set to the minimum of `levels`.
2664
+ vmax : float, optional
2665
+ Ignored but included for consistency with other normalizers.
2666
+ Set to the minimum of `levels`.
2667
+ clip : bool, optional
2668
+ Whether to clip values falling outside of the minimum
2669
+ and maximum of `levels`.
2670
+
2671
+ See also
2672
+ --------
2673
+ ultraplot.constructor.Norm
2674
+ ultraplot.colors.DiscreteNorm
2675
+
2676
+ Note
2677
+ ----
2678
+ The algorithm this normalizer uses to select normalized values
2679
+ in-between level list indices is adapted from the algorithm
2680
+ `~matplotlib.colors.LinearSegmentedColormap` uses to select channel
2681
+ values in-between segment data points (hence the name `SegmentedNorm`).
2682
+
2683
+ Example
2684
+ -------
2685
+ In the below example, unevenly spaced levels are passed to
2686
+ `~matplotlib.axes.Axes.contourf`, resulting in the automatic
2687
+ application of `SegmentedNorm`.
2688
+
2689
+ >>> import ultraplot as pplt
2690
+ >>> import numpy as np
2691
+ >>> levels = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]
2692
+ >>> data = 10 ** (3 * np.random.rand(10, 10))
2693
+ >>> fig, ax = pplt.subplots()
2694
+ >>> ax.contourf(data, levels=levels)
2695
+ """
2696
+ # WARNING: Tried using descending levels by adding 1 - yq to __call__() and
2697
+ # inverse() but then tick labels fail. Instead just silently reverse here and
2698
+ # the corresponding DiscreteLocator should enforce the descending axis.
2699
+ levels, _ = _sanitize_levels(levels)
2700
+ dest = np.linspace(0, 1, len(levels))
2701
+ vmin = np.min(levels)
2702
+ vmax = np.max(levels)
2703
+ super().__init__(vmin=vmin, vmax=vmax, clip=clip)
2704
+ self._x = self.boundaries = levels # 'boundaries' are used in PlotAxes
2705
+ self._y = dest
2706
+
2707
+ def __call__(self, value, clip=None):
2708
+ """
2709
+ Normalize the data values to 0-1. Inverse of `~SegmentedNorm.inverse`.
2710
+
2711
+ Parameters
2712
+ ----------
2713
+ value : numeric
2714
+ The data to be normalized.
2715
+ clip : bool, default: ``self.clip``
2716
+ Whether to clip values falling outside of the minimum and maximum levels.
2717
+ """
2718
+ if clip is None: # builtin clipping
2719
+ clip = self.clip
2720
+ if clip: # numpy.clip can handle masked arrays
2721
+ value = np.clip(value, self.vmin, self.vmax)
2722
+ xq, is_scalar = self.process_value(value)
2723
+ yq = _interpolate_extrapolate_vector(xq, self._x, self._y)
2724
+ if is_scalar:
2725
+ yq = np.atleast_1d(yq)[0]
2726
+ return yq
2727
+
2728
+ def inverse(self, value):
2729
+ """
2730
+ Inverse of `~SegmentedNorm.__call__`.
2731
+
2732
+ Parameters
2733
+ ----------
2734
+ value : numeric
2735
+ The data to be un-normalized.
2736
+ """
2737
+ yq, is_scalar = self.process_value(value)
2738
+ xq = _interpolate_extrapolate_vector(yq, self._y, self._x)
2739
+ if is_scalar:
2740
+ xq = np.atleast_1d(xq)[0]
2741
+ return xq
2742
+
2743
+
2744
+ class DivergingNorm(mcolors.Normalize):
2745
+ """
2746
+ Normalizer that ensures some central data value lies at the central
2747
+ colormap color. The default central value is ``0``.
2748
+ """
2749
+
2750
+ def __str__(self):
2751
+ return type(self).__name__ + f"(center={self.vcenter!r})"
2752
+
2753
+ def __init__(self, vcenter=0, vmin=None, vmax=None, fair=True, clip=None):
2754
+ """
2755
+ Parameters
2756
+ ----------
2757
+ vcenter : float, default: 0
2758
+ The data value corresponding to the central colormap position.
2759
+ vmin : float, optional
2760
+ The minimum data value.
2761
+ vmax : float, optional
2762
+ The maximum data value.
2763
+ fair : bool, optional
2764
+ If ``True`` (default), the speeds of the color gradations on either side
2765
+ of the center point are equal, but colormap colors may be omitted. If
2766
+ ``False``, all colormap colors are included, but the color gradations on
2767
+ one side may be faster than the other side. ``False`` should be used with
2768
+ great care, as it may result in a misleading interpretation of your data.
2769
+ clip : bool, optional
2770
+ Whether to clip values falling outside of `vmin` and `vmax`.
2771
+
2772
+ See also
2773
+ --------
2774
+ ultraplot.constructor.Norm
2775
+ """
2776
+ # NOTE: This post is an excellent summary of matplotlib's DivergingNorm history:
2777
+ # https://github.com/matplotlib/matplotlib/issues/15336#issuecomment-535291287
2778
+ # NOTE: This is a stale PR that plans to implement the same features.
2779
+ # https://github.com/matplotlib/matplotlib/pull/15333#issuecomment-537545430
2780
+ # Since ultraplot is starting without matplotlib's baggage we can just implement
2781
+ # a diverging norm like they would prefer if they didn't have to worry about
2782
+ # confusing users: single class, default "fair" scaling that can be turned off.
2783
+ super().__init__(vmin, vmax, clip)
2784
+ self.vcenter = vcenter
2785
+ self.fair = fair
2786
+
2787
+ def __call__(self, value, clip=None):
2788
+ """
2789
+ Normalize the data values to 0-1.
2790
+
2791
+ Parameters
2792
+ ----------
2793
+ value : numeric
2794
+ The data to be normalized.
2795
+ clip : bool, default: ``self.clip``
2796
+ Whether to clip values falling outside of `vmin` and `vmax`.
2797
+ """
2798
+ xq, is_scalar = self.process_value(value)
2799
+ self.autoscale_None(xq) # sets self.vmin, self.vmax if None
2800
+ if clip is None: # builtin clipping
2801
+ clip = self.clip
2802
+ if clip: # note that np.clip can handle masked arrays
2803
+ value = np.clip(value, self.vmin, self.vmax)
2804
+ if self.vmin > self.vmax:
2805
+ raise ValueError("vmin must be less than or equal to vmax.")
2806
+ elif self.vmin == self.vmax:
2807
+ x = [self.vmin, self.vmax]
2808
+ y = [0.0, 0.0]
2809
+ elif self.vcenter >= self.vmax:
2810
+ x = [self.vmin, self.vcenter]
2811
+ y = [0.0, 0.5]
2812
+ elif self.vcenter <= self.vmin:
2813
+ x = [self.vcenter, self.vmax]
2814
+ y = [0.5, 1.0]
2815
+ elif self.fair:
2816
+ offset = max(abs(self.vcenter - self.vmin), abs(self.vmax - self.vcenter))
2817
+ x = [self.vcenter - offset, self.vcenter + offset]
2818
+ y = [0.0, 1.0]
2819
+ else:
2820
+ x = [self.vmin, self.vcenter, self.vmax]
2821
+ y = [0.0, 0.5, 1.0]
2822
+ yq = _interpolate_extrapolate_vector(xq, x, y)
2823
+ if is_scalar:
2824
+ yq = np.atleast_1d(yq)[0]
2825
+ return yq
2826
+
2827
+ def autoscale_None(self, z):
2828
+ """
2829
+ Get vmin and vmax, and then clip at vcenter.
2830
+ """
2831
+ super().autoscale_None(z)
2832
+ if self.vmin > self.vcenter:
2833
+ self.vmin = self.vcenter
2834
+ if self.vmax < self.vcenter:
2835
+ self.vmax = self.vcenter
2836
+
2837
+
2838
+ def _init_color_database():
2839
+ """
2840
+ Initialize the subclassed database.
2841
+ """
2842
+ database = mcolors._colors_full_map
2843
+ if not isinstance(database, ColorDatabase):
2844
+ database = mcolors._colors_full_map = ColorDatabase(database)
2845
+ if hasattr(mcolors, "colorConverter"): # suspect deprecation is coming soon
2846
+ mcolors.colorConverter.cache = database.cache
2847
+ mcolors.colorConverter.colors = database
2848
+ return database
2849
+
2850
+
2851
+ def _init_cmap_database():
2852
+ """
2853
+ Initialize the subclassed database.
2854
+ """
2855
+ # We override the matplotlib base class
2856
+ # to add some functionality to it. Key features includes
2857
+ # - key insensitive lookup
2858
+ # - allow for dynamically generated shifted or reversed colormaps
2859
+ # with the extensions _r and _s(hifted)
2860
+ # This means we have to collect the base colormaps
2861
+ # and register them under the new object
2862
+ database = mcm._colormaps # shallow copy of mpl's colormaps
2863
+ if not isinstance(database, ColormapDatabase):
2864
+ # Collect the mpl colormaps and include them
2865
+ # in ultraplot's registry
2866
+ database = {
2867
+ key: value
2868
+ for key, value in database.items()
2869
+ if not key.endswith("_r") and not key.endswith("_shifted")
2870
+ }
2871
+ database = ColormapDatabase(database)
2872
+ setattr(
2873
+ mcm, "_colormaps", database
2874
+ ) # not sure if this is necessary since colormaps is a (shallow?) copy of _colormaps
2875
+ setattr(mpl, "colormaps", database) # this is necessary
2876
+ return database
2877
+
2878
+
2879
+ def _get_cmap_subtype(name, subtype):
2880
+ """
2881
+ Get a colormap belonging to a particular class. If none are found then raise
2882
+ a useful error message that omits colormaps from other classes.
2883
+ """
2884
+ # NOTE: Right now this is just used in rc validation but could be used elsewhere
2885
+ if subtype == "discrete":
2886
+ cls = DiscreteColormap
2887
+ elif subtype == "continuous":
2888
+ cls = ContinuousColormap
2889
+ elif subtype == "perceptual":
2890
+ cls = PerceptualColormap
2891
+ else:
2892
+ raise RuntimeError(f"Invalid subtype {subtype!r}.")
2893
+ cmap = _cmap_database.get_cmap(name)
2894
+ if not isinstance(cmap, cls):
2895
+ names = sorted(k for k, v in _cmap_database.items() if isinstance(v, cls))
2896
+ raise ValueError(
2897
+ f"Invalid {subtype} colormap name {name!r}. Options are: "
2898
+ + ", ".join(map(repr, names))
2899
+ + "."
2900
+ )
2901
+ return cmap
2902
+
2903
+
2904
+ def _translate_cmap(cmap, lut=None, cyclic=None, listedthresh=None):
2905
+ """
2906
+ Translate the input argument to a ultraplot colormap subclass. Auto-detect
2907
+ cyclic colormaps based on names and re-apply default lookup table size.
2908
+ """
2909
+ # Parse args
2910
+ # WARNING: Apply default 'cyclic' property to native matplotlib colormaps
2911
+ # based on known names. Maybe slightly dangerous but cleanest approach
2912
+ lut = _not_none(lut, rc["image.lut"])
2913
+ cyclic = _not_none(cyclic, cmap.name and cmap.name.lower() in CMAPS_CYCLIC)
2914
+ listedthresh = _not_none(listedthresh, rc["cmap.listedthresh"])
2915
+
2916
+ # Translate the colormap
2917
+ # WARNING: Here we ignore 'N' in order to respect ultraplotrc lut sizes
2918
+ # when initializing ultraplot.
2919
+ bad = cmap._rgba_bad
2920
+ under = cmap._rgba_under
2921
+ over = cmap._rgba_over
2922
+ name = cmap.name
2923
+ if isinstance(cmap, (DiscreteColormap, ContinuousColormap)):
2924
+ pass
2925
+ elif isinstance(cmap, mcolors.LinearSegmentedColormap):
2926
+ data = dict(cmap._segmentdata)
2927
+ cmap = ContinuousColormap(name, data, N=lut, gamma=cmap._gamma, cyclic=cyclic)
2928
+ elif isinstance(cmap, mcolors.ListedColormap):
2929
+ colors = list(cmap.colors)
2930
+ if len(colors) > listedthresh: # see notes at top of file
2931
+ cmap = ContinuousColormap.from_list(name, colors, N=lut, cyclic=cyclic)
2932
+ else:
2933
+ cmap = DiscreteColormap(colors, name)
2934
+ elif isinstance(cmap, mcolors.Colormap): # base class
2935
+ pass
2936
+ else:
2937
+ raise ValueError(
2938
+ f"Invalid colormap type {type(cmap).__name__!r}. "
2939
+ "Must be instance of matplotlib.colors.Colormap."
2940
+ )
2941
+
2942
+ # Apply hidden settings
2943
+ cmap._rgba_bad = bad
2944
+ cmap._rgba_under = under
2945
+ cmap._rgba_over = over
2946
+
2947
+ return cmap
2948
+
2949
+
2950
+ class _ColorCache(dict):
2951
+ """
2952
+ Replacement for the native color cache.
2953
+ """
2954
+
2955
+ def __getitem__(self, key):
2956
+ """
2957
+ Get the standard color, colormap color, or color cycle color.
2958
+ """
2959
+ # NOTE: Matplotlib 'color' args are passed to to_rgba, which tries to read
2960
+ # directly from cache and if that fails, sanitizes input, which raises
2961
+ # error on receiving (colormap, idx) tuple. So we have to override cache.
2962
+ return self._get_rgba(*key)
2963
+
2964
+ def _get_rgba(self, arg, alpha):
2965
+ """
2966
+ Try to get the color from the registered colormap or color cycle.
2967
+ """
2968
+ key = (arg, alpha)
2969
+ if isinstance(arg, str) or not np.iterable(arg) or len(arg) != 2:
2970
+ return dict.__getitem__(self, key)
2971
+ if not isinstance(arg[0], str) or not isinstance(arg[1], Number):
2972
+ return dict.__getitem__(self, key)
2973
+ # Try to get the colormap
2974
+ try:
2975
+ cmap = _cmap_database[arg[0]]
2976
+ except (KeyError, TypeError):
2977
+ return dict.__getitem__(self, key)
2978
+ # Read the colormap value
2979
+ if isinstance(cmap, DiscreteColormap):
2980
+ if not 0 <= arg[1] < len(cmap.colors):
2981
+ raise ValueError(
2982
+ f"Color cycle sample for {arg[0]!r} cycle must be "
2983
+ f"between 0 and {len(cmap.colors) - 1}, got {arg[1]}."
2984
+ )
2985
+ rgba = cmap.colors[arg[1]] # draw from list of colors
2986
+ else:
2987
+ if not 0 <= arg[1] <= 1:
2988
+ raise ValueError(
2989
+ f"Colormap sample for {arg[0]!r} colormap must be "
2990
+ f"between 0 and 1, got {arg[1]}."
2991
+ )
2992
+ rgba = cmap(arg[1]) # get color selection
2993
+ # Return the colormap value
2994
+ rgba = to_rgba(rgba)
2995
+ a = _not_none(alpha, rgba[3])
2996
+ return (*rgba[:3], a)
2997
+
2998
+
2999
+ class ColorDatabase(MutableMapping, dict):
3000
+ """
3001
+ Dictionary subclass used to replace the builtin matplotlib color database.
3002
+ See `~ColorDatabase.__getitem__` for details.
3003
+ """
3004
+
3005
+ _colors_replace = (
3006
+ ("grey", "gray"), # British --> American synonyms
3007
+ ("ochre", "ocher"), # ...
3008
+ ("kelley", "kelly"), # backwards compatibility to correct spelling
3009
+ )
3010
+
3011
+ def __delitem__(self, key):
3012
+ key = self._parse_key(key)
3013
+ dict.__delitem__(self, key)
3014
+ self.cache.clear()
3015
+
3016
+ def __init__(self, mapping=None):
3017
+ """
3018
+ Parameters
3019
+ ----------
3020
+ mapping : dict-like, optional
3021
+ The colors.
3022
+ """
3023
+ # NOTE: Tested with and without standardization and speedup is marginal
3024
+ self._cache = _ColorCache()
3025
+ mapping = mapping or {}
3026
+ for key, value in mapping.items():
3027
+ self.__setitem__(key, value)
3028
+
3029
+ def __getitem__(self, key):
3030
+ """
3031
+ Get a color. Translates ``grey`` into ``gray`` and supports retrieving
3032
+ colors "on-the-fly" from registered colormaps and color cycles.
3033
+
3034
+ * For a colormap, use e.g. ``color=('Blues', 0.8)``.
3035
+ The number is the colormap index, and must be between 0 and 1.
3036
+ * For a color cycle, use e.g. ``color=('colorblind', 2)``.
3037
+ The number is the color list index.
3038
+
3039
+ This works everywhere that colors are used in matplotlib, for
3040
+ example as `color`, `edgecolor', or `facecolor` keyword arguments
3041
+ passed to `~ultraplot.axes.PlotAxes` commands.
3042
+ """
3043
+ key = self._parse_key(key)
3044
+ return dict.__getitem__(self, key)
3045
+
3046
+ def __setitem__(self, key, value):
3047
+ """
3048
+ Add a color. Translates ``grey`` into ``gray`` and clears the
3049
+ cache. The color must be a string.
3050
+ """
3051
+ # Always standardize assignments.
3052
+ key = self._parse_key(key)
3053
+ dict.__setitem__(self, key, value)
3054
+ self.cache.clear()
3055
+
3056
+ def _parse_key(self, key):
3057
+ """
3058
+ Parse the color key. Currently this just translates grays.
3059
+ """
3060
+ if not isinstance(key, str):
3061
+ raise ValueError(f"Invalid color name {key!r}. Must be string.")
3062
+ if isinstance(key, str) and len(key) > 1: # ignore base colors
3063
+ key = key.lower()
3064
+ for sub, rep in self._colors_replace:
3065
+ key = key.replace(sub, rep)
3066
+ return key
3067
+
3068
+ @property
3069
+ def cache(self):
3070
+ # Matplotlib uses 'cache' but treat '_cache' as synonym
3071
+ # to guard against private API changes.
3072
+ return self._cache
3073
+
3074
+
3075
+ class ColormapDatabase(mcm.ColormapRegistry):
3076
+ """
3077
+ Dictionary subclass used to replace the matplotlib
3078
+ colormap registry. See `~ColormapDatabase.__getitem__` and
3079
+ `~ColormapDatabase.__setitem__` for details.
3080
+ """
3081
+
3082
+ _regex_grays = re.compile(r"\A(grays)(_r|_s)*\Z", flags=re.IGNORECASE)
3083
+ _regex_suffix = re.compile(r"(_r|_s)*\Z", flags=re.IGNORECASE)
3084
+
3085
+ def __init__(self, kwargs):
3086
+ """
3087
+ Parameters
3088
+ ----------
3089
+ kwargs : dict-like
3090
+ The source dictionary.
3091
+ """
3092
+ super().__init__(kwargs)
3093
+ # The colormap is initialized with all the base colormaps
3094
+ # We have to change the classes internally to Perceptual, Continuous or Discrete
3095
+ # such that ultraplot knows what these objects are. We piggy back on the registering mechanism
3096
+ # by overriding matplotlib's behavior
3097
+ for name in tuple(self._cmaps.keys()):
3098
+ self.register(self._cmaps[name], name=name)
3099
+
3100
+ def _translate_deprecated(self, key):
3101
+ """
3102
+ Check if a colormap has been deprecated.
3103
+ """
3104
+ # WARNING: Must search only for case-sensitive *capitalized* names or we would
3105
+ # helpfully "redirect" user to SciVisColor cmap when they are trying to
3106
+ # generate open-color monochromatic cmaps and would disallow some color names
3107
+ if isinstance(key, str):
3108
+ test = self._regex_suffix.sub("", key)
3109
+ else:
3110
+ test = None
3111
+ if not self._has_item(test) and test in CMAPS_REMOVED:
3112
+ version = CMAPS_REMOVED[test]
3113
+ raise ValueError(
3114
+ f"The colormap name {key!r} was removed in version {version}."
3115
+ )
3116
+ if not self._has_item(test) and test in CMAPS_RENAMED:
3117
+ test_new, version = CMAPS_RENAMED[test]
3118
+ warnings._warn_ultraplot(
3119
+ f"The colormap name {test!r} was deprecated in version {version} "
3120
+ f"and may be removed in {warnings.next_release()}. Please use "
3121
+ f"the colormap name {test_new!r} instead."
3122
+ )
3123
+ key = re.sub(test, test_new, key, flags=re.IGNORECASE)
3124
+ return key
3125
+
3126
+ def _translate_key(self, original_key, mirror=True):
3127
+ """
3128
+ Return the sanitized colormap name. Used for lookups and assignments.
3129
+ """
3130
+ # Sanitize key
3131
+ if not isinstance(original_key, str):
3132
+ raise KeyError(f"Invalid key {original_key!r}. Key must be a string.")
3133
+
3134
+ key = original_key.lower()
3135
+ key = self._regex_grays.sub(r"greys\2", key)
3136
+
3137
+ # Handle reversal
3138
+ reverse = key.endswith("_r")
3139
+ if reverse:
3140
+ key = key.rstrip("_r")
3141
+
3142
+ # Check if the key exists in builtin colormaps
3143
+ if self._has_item(key):
3144
+ return key + "_r" if reverse else key
3145
+
3146
+ # Mirror diverging colormaps
3147
+ if mirror:
3148
+ # Check for diverging colormaps
3149
+ key_mirror = CMAPS_DIVERGING.get(key, None)
3150
+ if key_mirror and self._has_item(key_mirror):
3151
+ return key_mirror + "_r" if not reverse else key_mirror
3152
+
3153
+ # Check for reversed builtin colormaps
3154
+ if self._has_item(key + "_r"):
3155
+ return key if reverse else key + "_r"
3156
+
3157
+ # Try mirroring the non-lowered key
3158
+ if reverse:
3159
+ original_key = original_key.strip("_r")
3160
+ half = len(original_key) // 2
3161
+ mirrored_key = original_key[half:] + original_key[:half]
3162
+ if self._has_item(mirrored_key):
3163
+ return mirrored_key + "_r" if not reverse else mirrored_key
3164
+ # Restore key
3165
+ if reverse:
3166
+ original_key = original_key + "_r"
3167
+ # If no match found, return the original key
3168
+ return key
3169
+
3170
+ def _has_item(self, key):
3171
+ return key in self._cmaps
3172
+
3173
+ def get_cmap(self, cmap):
3174
+ return self.__getitem__(cmap)
3175
+
3176
+ def __getitem__(self, key):
3177
+ """
3178
+ Get the colormap with flexible input keys.
3179
+ """
3180
+ # Sanitize key
3181
+ key = self._translate_deprecated(key)
3182
+ key = self._translate_key(key, mirror=True)
3183
+ shift = key.endswith("_s") and not self._has_item(key)
3184
+ if shift:
3185
+ key = key.rstrip("_s")
3186
+ reverse = key.endswith("_r") and not self._has_item(key)
3187
+
3188
+ if reverse:
3189
+ key = key.rstrip("_r")
3190
+ # Retrieve colormap
3191
+ if self._has_item(key):
3192
+ value = self._cmaps[key].copy()
3193
+ else:
3194
+ raise KeyError(
3195
+ f"Invalid colormap or color cycle name {key!r}. Options are: "
3196
+ + ", ".join(map(repr, self))
3197
+ + "."
3198
+ )
3199
+ # Modify colormap
3200
+ if reverse:
3201
+ value = value.reversed()
3202
+ if shift:
3203
+ value = value.shifted(180)
3204
+ return value
3205
+
3206
+ def register(self, cmap, *, name=None, force=False):
3207
+ """
3208
+ Add the colormap after validating and converting.
3209
+ """
3210
+ if name is None and cmap.name is None:
3211
+ raise ValueError("Please register the cmap under a string")
3212
+ elif name is None and cmap.name is not None:
3213
+ name = cmap.name
3214
+ name = self._translate_key(name, mirror=False)
3215
+ cmap = _translate_cmap(cmap)
3216
+ # The builtin cmaps are a different class
3217
+ # ultraplot internally uses different classes for the different colormaps
3218
+ if force and name in self._cmaps:
3219
+ # surpress warning if the colormap is not generate by ultraplot
3220
+ if name not in self._builtin_cmaps:
3221
+ print(f"Overwriting {name!r} that was already registered")
3222
+ self._cmaps[name] = cmap.copy()
3223
+
3224
+
3225
+ # Initialize databases
3226
+ _cmap_database = _init_cmap_database()
3227
+ _color_database = _init_color_database()
3228
+
3229
+ # Deprecated
3230
+ (
3231
+ ListedColormap,
3232
+ LinearSegmentedColormap,
3233
+ PerceptuallyUniformColormap,
3234
+ LinearSegmentedNorm,
3235
+ ) = warnings._rename_objs( # noqa: E501
3236
+ "0.8.0",
3237
+ ListedColormap=DiscreteColormap,
3238
+ LinearSegmentedColormap=ContinuousColormap,
3239
+ PerceptuallyUniformColormap=PerceptualColormap,
3240
+ LinearSegmentedNorm=SegmentedNorm,
3241
+ )