diff --git a/examples/user_guide/Styling_Plots.ipynb b/examples/user_guide/Styling_Plots.ipynb index cd70884314..b1a9d5cc61 100644 --- a/examples/user_guide/Styling_Plots.ipynb +++ b/examples/user_guide/Styling_Plots.ipynb @@ -258,6 +258,38 @@ "img.options(cmap='PiYG', color_levels=5) + img.options(cmap='PiYG', color_levels=11) " ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Custom color intervals\n", + "\n", + "In addition to a simple integer defining the number of discrete levels, the ``color_levels`` option also allows defining a set of custom intervals. This can be useful for defining a fixed scale, such as the Saffir-Simpson hurricane wind scale. Below we declare the color levels along with a list of colors, declaring the scale. Note that the levels define the intervals to map each color to, so if there are N colors we have to define N+1 levels.\n", + "\n", + "Having defined the scale we can generate a theoretical hurricane path with wind speed values and use the ``color_levels`` and ``cmap`` to supply the custom color scale:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "levels = [0, 38, 73, 95, 110, 130, 156, 999] \n", + "colors = ['#5ebaff', '#00faf4', '#ffffcc', '#ffe775', '#ffc140', '#ff8f20', '#ff6060']\n", + "\n", + "path = [\n", + " (-75.1, 23.1, 0), (-76.2, 23.8, 0), (-76.9, 25.4, 0), (-78.4, 26.1, 39), (-79.6, 26.2, 39),\n", + " (-80.3, 25.9, 39), (-82.0, 25.1, 74), (-83.3, 24.6, 74), (-84.7, 24.4, 96), (-85.9, 24.8, 111),\n", + " (-87.7, 25.7, 111), (-89.2, 27.2, 131), (-89.6, 29.3, 156), (-89.6, 30.2, 156), (-89.1, 32.6, 131),\n", + " (-88.0, 35.6, 111), (-85.3, 38.6, 96)\n", + "]\n", + "\n", + "hv.Path([path], vdims='Wind Speed').options(\n", + " color_index='Wind Speed', color_levels=levels, cmap=colors, line_width=8, colorbar=True, width=450\n", + ")" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index 1336c20574..87bd3cd429 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -22,7 +22,7 @@ from ...core import util from ...streams import Buffer from ..plot import GenericElementPlot, GenericOverlayPlot -from ..util import dynamic_update, process_cmap +from ..util import dynamic_update, process_cmap, color_intervals from .plot import BokehPlot, TOOLS from .util import (mpl_to_bokeh, get_tab_title, py2js_tickformatter, rgba_tuple, recursive_model_update, glyph_order, @@ -986,8 +986,9 @@ class ColorbarPlot(ElementPlot): 'opts': {'location': 'bottom_right', 'orientation': 'horizontal'}}} - color_levels = param.Integer(default=None, doc=""" - Number of discrete colors to use when colormapping.""") + color_levels = param.ClassSelector(default=None, class_=(int, list), doc=""" + Number of discrete colors to use when colormapping or a set of color + intervals defining the range of values to map each color to.""") colorbar = param.Boolean(default=False, doc=""" Whether to display a colorbar.""") @@ -1079,7 +1080,18 @@ def _get_colormapper(self, dim, element, ranges, style, factors=None, colors=Non if isinstance(cmap, dict) and factors: palette = [cmap.get(f, nan_colors.get('NaN', self._default_nan)) for f in factors] else: - palette = process_cmap(cmap, self.color_levels or ncolors, categorical=ncolors is not None) + if isinstance(self.color_levels, int): + ncolors = self.color_levels + elif isinstance(self.color_levels, list): + ncolors = len(self.color_levels) - 1 + if isinstance(cmap, list) and len(cmap) != ncolors: + raise ValueError('The number of colors in the colormap ' + 'must match the intervals defined in the ' + 'color_levels, expected %d colors found %d.' + % (ncolors, len(cmap))) + palette = process_cmap(cmap, ncolors, categorical=ncolors is not None) + if isinstance(self.color_levels, list): + palette = color_intervals(palette, self.color_levels, clip=(low, high)) colormapper, opts = self._get_cmapper_opts(low, high, factors, nan_colors) cmapper = self.handles.get(name) diff --git a/holoviews/plotting/mpl/element.py b/holoviews/plotting/mpl/element.py index 1986f62d2e..92618055f8 100644 --- a/holoviews/plotting/mpl/element.py +++ b/holoviews/plotting/mpl/element.py @@ -12,7 +12,7 @@ CompositeOverlay, Element3D, Element) from ...core.options import abbreviated_exception from ..plot import GenericElementPlot, GenericOverlayPlot -from ..util import dynamic_update, process_cmap +from ..util import dynamic_update, process_cmap, color_intervals from .plot import MPLPlot, mpl_rc_context from .util import wrap_formatter from distutils.version import LooseVersion @@ -479,8 +479,9 @@ class ColorbarPlot(ElementPlot): colorbar = param.Boolean(default=False, doc=""" Whether to draw a colorbar.""") - color_levels = param.Integer(default=None, doc=""" - Number of discrete colors to use when colormapping.""") + color_levels = param.ClassSelector(default=None, class_=(int, list), doc=""" + Number of discrete colors to use when colormapping or a set of color + intervals defining the range of values to map each color to.""") clipping_colors = param.Dict(default={}, doc=""" Dictionary to specify colors for clipped values, allows @@ -629,9 +630,18 @@ def _norm_kwargs(self, element, ranges, opts, vdim, prefix=''): opts[prefix+'vmin'] = clim[0] opts[prefix+'vmax'] = clim[1] - # Check whether the colorbar should indicate clipping + cmap = opts.get(prefix+'cmap', 'viridis') if values.dtype.kind not in 'OSUM': - ncolors = self.color_levels + ncolors = None + if isinstance(self.color_levels, int): + ncolors = self.color_levels + elif isinstance(self.color_levels, list): + ncolors = len(self.color_levels) - 1 + if isinstance(cmap, list) and len(cmap) != ncolors: + raise ValueError('The number of colors in the colormap ' + 'must match the intervals defined in the ' + 'color_levels, expected %d colors found %d.' + % (ncolors, len(cmap))) try: el_min, el_max = np.nanmin(values), np.nanmax(values) except ValueError: @@ -649,7 +659,6 @@ def _norm_kwargs(self, element, ranges, opts, vdim, prefix=''): self._cbar_extend = 'max' # Define special out-of-range colors on colormap - cmap = opts.get(prefix+'cmap', 'viridis') colors = {} for k, val in self.clipping_colors.items(): if val == 'transparent': @@ -672,6 +681,8 @@ def _norm_kwargs(self, element, ranges, opts, vdim, prefix=''): for f in factors] else: palette = process_cmap(cmap, ncolors, categorical=categorical) + if isinstance(self.color_levels, list): + palette = color_intervals(palette, self.color_levels, clip=(vmin, vmax)) cmap = mpl_colors.ListedColormap(palette) if 'max' in colors: cmap.set_over(**colors['max']) if 'min' in colors: cmap.set_under(**colors['min']) diff --git a/holoviews/plotting/util.py b/holoviews/plotting/util.py index f6fdea2f25..7dfee18e58 100644 --- a/holoviews/plotting/util.py +++ b/holoviews/plotting/util.py @@ -1,4 +1,4 @@ -from __future__ import unicode_literals, absolute_import +from __future__ import unicode_literals, absolute_import, division from collections import defaultdict, namedtuple @@ -836,6 +836,29 @@ def process_cmap(cmap, ncolors=None, provider=None, categorical=False): return palette +def color_intervals(colors, levels, clip=None, N=255): + """ + Maps a set of intervals to colors given a fixed color range. + """ + if len(colors) != len(levels)-1: + raise ValueError('The number of colors in the colormap ' + 'must match the intervals defined in the ' + 'color_levels, expected %d colors found %d.' + % (N, len(colors))) + intervals = np.diff(levels) + cmin, cmax = min(levels), max(levels) + interval = cmax-cmin + cmap = [] + for intv, c in zip(intervals, colors): + cmap += [c]*int(round(N*(intv/interval))) + if clip is not None: + clmin, clmax = clip + lidx = int(round(N*((clmin-cmin)/interval))) + uidx = int(round(N*((cmax-clmax)/interval))) + cmap = cmap[lidx:N-uidx] + return cmap + + def dim_axis_label(dimensions, separator=', '): """ Returns an axis label for one or more dimensions. diff --git a/tests/plotting/testplotutils.py b/tests/plotting/testplotutils.py index 1824a503a4..608e8ae371 100644 --- a/tests/plotting/testplotutils.py +++ b/tests/plotting/testplotutils.py @@ -15,7 +15,7 @@ from holoviews.plotting.util import ( compute_overlayable_zorders, get_min_distance, process_cmap, initialize_dynamic, split_dmap_overlay, _get_min_distance_numpy, - bokeh_palette_to_palette, mplcmap_to_palette) + bokeh_palette_to_palette, mplcmap_to_palette, color_intervals) from holoviews.streams import PointerX try: @@ -565,6 +565,20 @@ def test_bokeh_palette_perceptually_uniform_reverse(self): colors = bokeh_palette_to_palette('viridis_r', 4) self.assertEqual(colors, ['#440154', '#30678D', '#35B778', '#FDE724'][::-1]) + def test_color_intervals(self): + levels = [0, 38, 73, 95, 110, 130, 156] + colors = ['#5ebaff', '#00faf4', '#ffffcc', '#ffe775', '#ffc140', '#ff8f20'] + cmap = color_intervals(colors, levels, N=10) + self.assertEqual(cmap, ['#5ebaff', '#5ebaff', '#00faf4', + '#00faf4', '#ffffcc', '#ffe775', + '#ffc140', '#ff8f20', '#ff8f20']) + + def test_color_intervals_clipped(self): + levels = [0, 38, 73, 95, 110, 130, 156, 999] + colors = ['#5ebaff', '#00faf4', '#ffffcc', '#ffe775', '#ffc140', '#ff8f20', '#ff6060'] + cmap = color_intervals(colors, levels, clip=(10, 90), N=100) + self.assertEqual(cmap, ['#5ebaff', '#5ebaff', '#5ebaff', '#00faf4', '#00faf4', + '#00faf4', '#00faf4', '#ffffcc']) class TestPlotUtils(ComparisonTestCase):