diff --git a/doc/user_guide/index.rst b/doc/user_guide/index.rst index 0476038aa1..c037f3abc6 100644 --- a/doc/user_guide/index.rst +++ b/doc/user_guide/index.rst @@ -75,6 +75,9 @@ These guides provide detail about specific additional features in HoloViews: * `Installing and Configuring HoloViews `_ Additional information about installation and configuration options. +* `Styling Plots `_ + How to control common styling options including color/style cycles and colormapping. + * `Plotting with Bokeh `_ Styling options and unique `Bokeh `_ features such as plot tools and using bokeh models directly. @@ -119,6 +122,7 @@ These guides provide detail about specific additional features in HoloViews: Working with large data Working with streaming data Creating interactive dashboards + Styling Plots Plotting with Bokeh Deploying Bokeh Apps Plotting with matplotlib diff --git a/examples/user_guide/Styling_Plots.ipynb b/examples/user_guide/Styling_Plots.ipynb new file mode 100644 index 0000000000..f9390614f7 --- /dev/null +++ b/examples/user_guide/Styling_Plots.ipynb @@ -0,0 +1,387 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import holoviews as hv\n", + "hv.extension('bokeh', 'matplotlib')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Since HoloViews supports a number of rendering backends, some of the styling options will differ between them. However, some fundamental concepts such as color cycles, colormapping, setting titles, and controlling legends are shared across backends. In this guide we will review how to apply these common styling options to plots. Once you know how to use these, you should be able to see how to use the options for specific backends covered in the [Plotting with bokeh](./Plotting_with_Bokeh.ipynb) and [Plotting with matplotlib](./Plotting_with_Matplotlib.ipynb) user guides." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Cycles and Palettes\n", + "\n", + "Frequently we want to plot multiple subsets of data, which is made easy by using ``Overlay`` and ``NdOverlay`` objects. When overlaying multiple elements of the same type they will need to be distinguished visually, and HoloViews provides two mechanisms for styling the different subsets automatically in those cases:\n", + "\n", + "* ``Cycle``: A Cycle defines a list of discrete styles\n", + "* ``Palette``: A Palette defines a continuous color space which will be sampled discretely" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Cycle\n", + "\n", + "A ``Cycle`` can be applied to any of the style options on an element. By default, most elements define a ``Cycle`` on the color property. Here we will create a overlay of three ``Points`` objects using the default cycles, then display it using the default cycles along with a copy where we changed the dot color and size using a custom ``Cycle``:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "points = (\n", + " hv.Points(np.random.randn(50, 2) ) *\n", + " hv.Points(np.random.randn(50, 2) + 1 ) *\n", + " hv.Points(np.random.randn(50, 2) * 0.5)\n", + ")\n", + "\n", + "color_cycle = hv.Cycle(['red', 'green', 'blue'])\n", + "points + points.options({'Points': {'color': color_cycle, 'size': 5}})" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Here color has been changed to cycle over the three provided colors, while size has been specified as a constant (though a cycle like `hv.Cycle([2,5,10])` could just as easily have been used for the size as well).\n", + "\n", + "#### Defaults\n", + "\n", + "In addition to defining custom color cycles by explicitly defining a list of colors, ``Cycle`` also defines a list of default Cycles generated from bokeh Palettes and matplotlib colormaps:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def format_list(l):\n", + " print(' '.join(sorted([k for k in l if '_r' not in k])))\n", + "\n", + "format_list(hv.Cycle.default_cycles.keys())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "(Here some of these Cycles have a reversed variant ending in `_r` that is not shown.)\n", + "\n", + "To use one of these default Cycles simply construct the Cycle with the corresponding key:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "xs = np.linspace(0, np.pi*2)\n", + "curves = hv.Overlay([hv.Curve(np.sin(xs+p)) for p in np.linspace(0, np.pi, 10)])\n", + "\n", + "curves.options({'Curve': {'color': hv.Cycle('Category20'), 'width': 600}})" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Markers and sizes\n", + "\n", + "The above examples focus on color Cycles, but Cycles may be used to define any style option. Here let's use them to cycle over a number of marker styles and sizes, which will be expanded by cycling over each item independently. In this case we are cycling over three Cycles, resulting in the following style combinations:\n", + "\n", + "1. ``{'color': '#30a2da', 'marker': 'x', 'size': 10}``\n", + "2. ``{'color': '#fc4f30', 'marker': '^', 'size': 5}``\n", + "3. ``{'color': '#e5ae38', 'marker': '+', 'size': 10}``" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "color = hv.Cycle(['#30a2da', '#fc4f30', '#e5ae38'])\n", + "markers = hv.Cycle(['x', '^', '+'])\n", + "sizes = hv.Cycle([10, 5])\n", + "points.options({'Points': {'marker': markers, 'size': sizes}})" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Palettes\n", + "\n", + "Palettes are similar to cycles, but treat a set of colors as a continuous colorspace to be sampled at regularly spaced intervals. Again they are made automatically available from existing colormaps:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "format_list(hv.Palette.colormaps.keys())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "(Here each colormap `X` has a corresponding version `X_r` with the values reversed; the `_r` variants are suppressed above.)\n", + "\n", + "As a simple example we will create a Palette from the Spectral colormap and apply it to an Overlay of 6 Ellipses. Comparing it to the Spectral ``Cycle`` we can immediately see that the Palette covers the entire color space spanned by the Spectral colormap, while the Cycle instead uses the first 6 colors of the Spectral colormap:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ellipses = hv.Overlay([hv.Ellipse(0, 0, s) for s in range(6)])\n", + "\n", + "ellipses.relabel('Palette').options({'Ellipse': dict(color=hv.Palette('Spectral'), line_width=5)}) +\\\n", + "ellipses.relabel('Cycle' ).options({'Ellipse': dict(color=hv.Cycle( 'Spectral'), line_width=5)})" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Thus if you want to have have a discrete set of distinguishable colors starting from a list of colors that vary slowly and continuously, you should usually supply it as a Palette, not a Cycle. Conversely, you should use a Cycle when you want to iterate through a specific list of colors, in order, without skipping around the list like a Palette will.\n", + "\n", + "\n", + "## Colormapping\n", + "\n", + "Color cycles and styles are useful for categorical plots and when overlaying multiple subsets, but when we want to map data values to a color it is better to use HoloViews' facilities for color mapping. Certain image-like types will apply colormapping automatically; e.g. for ``Image``, ``QuadMesh`` or ``HeatMap`` types the first value dimension is automatically mapped to the color. In other cases the values to colormap can be declared through the ``color_index``, which may reference any dimension by name or by numerical index.\n", + "\n", + "\n", + "#### Setting a colormap\n", + "\n", + "A HoloViews colormap can take a number of forms. By default, HoloViews will make bokeh Palettes and matplotlib colormaps available to reference by name when the respective backend is imported. It is also possible to declare a colormap as a list of hex or HTML colors.\n", + "\n", + "##### Named colormaps\n", + "\n", + "The full list of available named colormaps can be accessed using ``hv.plotting.list_cmaps``. To provide an overview we will create a layout of all available colormaps (except the reversed versions with the ``'_r'`` suffix)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%%output backend='matplotlib'\n", + "colormaps = hv.plotting.list_cmaps()\n", + "\n", + "spacing = np.linspace(0, 1, 64)[np.newaxis]\n", + "opts = dict(aspect=6, xaxis=None, yaxis=None, sublabel_format='')\n", + "hv.Layout([hv.Image(spacing, ydensity=1, label=cmap).options(cmap=cmap, **opts)\n", + " for cmap in colormaps if '_r' not in cmap]).options(vspace=0.1, hspace=0.1, transpose=True).cols(15)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To use one of these colormaps simply refer to it by name with the ``cmap`` style option:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ls = np.linspace(0, 10, 400)\n", + "xx, yy = np.meshgrid(ls, ls)\n", + "bounds=(-1,-1,1,1) # Coordinate system: (left, bottom, top, right)\n", + "img = hv.Image(np.sin(xx)*np.cos(yy), bounds=bounds)\n", + "\n", + "img.options(cmap='PiYG', colorbar=True, width=400)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note that the `cmap` style option discussed here is distinct from the `cmap` argument to the [Datashader `shade()` operation](14-Large_Data.ipynb). Both the option and the `shade()` argument accept similar specifications for colormapping your data, but the style option discussed here is dynamically applied \"client side\" (in your browser, using JavaScript) while the `shade()` argument uses datashader's Python-based functionality. Here we are focusing only on the client-side colormapping using the `cmap` option.\n", + "\n", + "##### Custom colormaps\n", + "\n", + "You can make your own custom colormaps by providing a list of hex colors:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "img.options(cmap=['#0000ff', '#8888ff', '#ffffff', '#ff8888', '#ff0000'], colorbar=True, width=400)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### Discrete color levels" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Lastly, existing colormaps can be made discrete by defining an integer number of ``color_levels``:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "img.options(cmap='PiYG', color_levels=10, colorbar=True, width=400)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Setting color ranges\n", + "\n", + "For an image-like element, color ranges are determined by the range of the `z` value dimension, and they can thus be controlled using the ``.redim.range`` method with `z`. As an example, let's set some values in the image array to NaN and then set the range to clip the data at 0 and 0.9. By declaring the ``clipping_colors`` option we can control what colors are used for NaN values and for values above and below the defined range:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "clipping = {'min': 'red', 'max': 'green', 'NaN': 'gray'}\n", + "opts = dict(cmap='Blues', colorbar=True, width=300, height=230, axiswise=True)\n", + "\n", + "arr = np.sin(xx)*np.cos(yy)\n", + "arr[:190, :127] = np.NaN\n", + "\n", + "original = hv.Image(arr, bounds=bounds).options(**opts)\n", + "colored = original.options(clipping_colors=clipping)\n", + "clipped = colored.redim.range(z=(0, 0.9))\n", + "\n", + "original + colored + clipped" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "By default (left plot above), the min and max values in the array map to the first color (white) and last color (dark blue) in the colormap, and NaNs are ``'transparent'`` (an RGBA tuple of (0, 0, 0, 0)), revealing the underlying plot background. When the specified `clipping_colors` are supplied (middle plot above), NaN values are now colored gray, but the plot is otherwise the same because the autoranging still ensures that no value is mapped outside the available color range. Finally, when the `z` range is reduced (right plot above), the color range is mapped from a different range of numerical `z` values, and some values now fall outside the range and are thus clipped to red or green as specified.\n", + " \n", + " \n", + " #### Other options\n", + "\n", + "* ``logz``: Enable logarithmic color scale (e.g. ``logz=True``)\n", + "* ``symmetric``: Ensures that the color scale is centered on zero (e.g. ``symmetric=True``)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Using color_index\n", + "\n", + "As mentioned above, when plotting elements that do not automatically map colors to certain dimensions, we can use the ``color_index`` option to do so explicitly. This allows colormapping both continuously valued and categorical values.\n", + "\n", + "#### Continuous values\n", + "\n", + "If we provide a continuous value for the ``color_index``, we have a continuous colormap and can enable a ``colorbar``:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "polygons = hv.Polygons([{('x', 'y'): hv.Ellipse(0, 0, (i, i)).array(), 'z': i} for i in range(1, 10)[::-1]], vdims='z')\n", + "\n", + "polygons.options(color_index='z', colorbar=True, width=380)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Categorical values" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Conversely, when mapping a categorical value into a set of colors, we automatically get a legend (which can be disabled using the ``show_legend`` option):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "categorical_points = hv.Points((np.random.rand(100), \n", + " np.random.rand(100), \n", + " np.random.choice(list('ABCD'), 100)), vdims='Category')\n", + "\n", + "categorical_points.sort('Category').options(color_index='Category', cmap='Category20', size=5)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Explicit color mapping\n", + "\n", + "Instead of using a listed colormap, you can provide an explicit mapping from category to color. Here we will map the categories 'A', 'B', 'C' and 'D' to specific colors:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "explicit_mapping = {'A': 'blue', 'B': 'red', 'C': 'green', 'D': 'purple'}\n", + "\n", + "categorical_points.sort('Category').options(color_index='Category', cmap=explicit_mapping, size=5)" + ] + } + ], + "metadata": { + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/holoviews/plotting/__init__.py b/holoviews/plotting/__init__.py index c07b88a955..c094d5ba38 100644 --- a/holoviews/plotting/__init__.py +++ b/holoviews/plotting/__init__.py @@ -10,6 +10,7 @@ from ..element import Area, Polygons from .plot import Plot from .renderer import Renderer, HTML_TAGS # noqa (API import) +from .util import list_cmaps # noqa (API import) from ..operation.stats import univariate_kde, bivariate_kde Compositor.register(Compositor("Distribution", univariate_kde, None, @@ -21,6 +22,11 @@ transfer_parameters=True, output_type=Polygons)) +DEFAULT_CYCLE = ['#30a2da', '#fc4f30', '#e5ae38', '#6d904f', '#8b8b8b', '#17becf', + '#9467bd', '#d62728', '#1f77b4', '#e377c2', '#8c564b', '#bcbd22'] + +Cycle.default_cycles['default_colors'] = DEFAULT_CYCLE + def public(obj): if not isinstance(obj, type): return False is_plot_or_cycle = any([issubclass(obj, bc) for bc in [Plot, Cycle]]) diff --git a/holoviews/plotting/bokeh/__init__.py b/holoviews/plotting/bokeh/__init__.py index b1f4b8a9f1..35e84dc875 100644 --- a/holoviews/plotting/bokeh/__init__.py +++ b/holoviews/plotting/bokeh/__init__.py @@ -138,10 +138,7 @@ AdjointLayoutPlot.registry[Histogram] = SideHistogramPlot AdjointLayoutPlot.registry[Spikes] = SideSpikesPlot - point_size = np.sqrt(6) # Matches matplotlib default -Cycle.default_cycles['default_colors'] = ['#30a2da', '#fc4f30', '#e5ae38', - '#6d904f', '#8b8b8b'] # Register bokeh.palettes with Palette and Cycle def colormap_generator(palette): diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index e2aba11064..1b9c98b536 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -974,6 +974,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.""") + colorbar = param.Boolean(default=False, doc=""" Whether to display a colorbar.""") @@ -1000,9 +1003,14 @@ class ColorbarPlot(ElementPlot): logz = param.Boolean(default=False, doc=""" Whether to apply log scaling to the z-axis.""") + symmetric = param.Boolean(default=False, doc=""" + Whether to make the colormap symmetric around zero.""") + _colorbar_defaults = dict(bar_line_color='black', label_standoff=8, major_tick_line_color='black') + _default_nan = '#8b8b8b' + def _draw_colorbar(self, plot, color_mapper): if CategoricalColorMapper and isinstance(color_mapper, CategoricalColorMapper): return @@ -1048,12 +1056,18 @@ def _get_colormapper(self, dim, element, ranges, style, factors=None, colors=Non low, high = ranges.get(dim.name) else: low, high = element.range(dim.name) + if self.symmetric: + sym_max = max(abs(low), high) + low, high = -sym_max, sym_max else: low, high = None, None cmap = colors or style.pop('cmap', 'viridis') - palette = process_cmap(cmap, ncolors) nan_colors = {k: rgba_tuple(v) for k, v in self.clipping_colors.items()} + 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) colormapper, opts = self._get_cmapper_opts(low, high, factors, nan_colors) cmapper = self.handles.get(name) diff --git a/holoviews/plotting/bokeh/raster.py b/holoviews/plotting/bokeh/raster.py index d40e9e2442..c243f8f32b 100644 --- a/holoviews/plotting/bokeh/raster.py +++ b/holoviews/plotting/bokeh/raster.py @@ -10,6 +10,8 @@ class RasterPlot(ColorbarPlot): + clipping_colors = param.Dict(default={'NaN': 'transparent'}) + show_legend = param.Boolean(default=False, doc=""" Whether to show legend for the plot.""") @@ -113,6 +115,8 @@ def get_data(self, element, ranges, style): class QuadMeshPlot(ColorbarPlot): + clipping_colors = param.Dict(default={'NaN': 'transparent'}) + show_legend = param.Boolean(default=False, doc=""" Whether to show legend for the plot.""") diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py index e3a807f6a9..81c4472ca2 100644 --- a/holoviews/plotting/bokeh/util.py +++ b/holoviews/plotting/bokeh/util.py @@ -34,7 +34,7 @@ from ...core.util import basestring, unique_array, callable_name, pd, dt64_to_dt from ...core.spaces import get_nested_dmaps, DynamicMap -from ..util import dim_axis_label, rgb2hex +from ..util import dim_axis_label, rgb2hex, COLOR_ALIASES # Conversion between matplotlib and bokeh markers markers = {'s': {'marker': 'square'}, @@ -64,7 +64,7 @@ def rgba_tuple(rgba): if isinstance(rgba, tuple): return tuple(int(c*255) if i<3 else c for i, c in enumerate(rgba)) else: - return rgba + return COLOR_ALIASES.get(rgba, rgba) def decode_bytes(array): @@ -109,7 +109,7 @@ def mpl_to_bokeh(properties): new_properties.update(markers.get(v, {'marker': v})) elif (k == 'color' or k.endswith('_color')) and not isinstance(v, dict): with abbreviated_exception(): - v = colors.ColorConverter.colors.get(v, v) + v = COLOR_ALIASES.get(v, v) if isinstance(v, tuple): with abbreviated_exception(): v = rgb2hex(v) diff --git a/holoviews/plotting/mpl/__init__.py b/holoviews/plotting/mpl/__init__.py index 9b3b0fb6c4..0081a34b11 100644 --- a/holoviews/plotting/mpl/__init__.py +++ b/holoviews/plotting/mpl/__init__.py @@ -79,10 +79,6 @@ def get_color_cycle(): set_style('default>1.5') else: set_style('default') - Cycle.default_cycles.update({'default_colors': get_color_cycle()}) -else: - Cycle.default_cycles['default_colors'] = ['#30a2da', '#fc4f30', '#e5ae38', - '#6d904f', '#8b8b8b'] # Define Palettes and cycles from matplotlib colormaps Palette.colormaps.update({cm: plt.get_cmap(cm) for cm in plt.cm.datad diff --git a/holoviews/plotting/mpl/element.py b/holoviews/plotting/mpl/element.py index 59bc83093b..c60dfdc255 100644 --- a/holoviews/plotting/mpl/element.py +++ b/holoviews/plotting/mpl/element.py @@ -1,9 +1,8 @@ -import math, copy +import math import param import numpy as np import matplotlib as mpl -import matplotlib.pyplot as plt import matplotlib.colors as mpl_colors from matplotlib import ticker from matplotlib.dates import date2num @@ -13,7 +12,7 @@ CompositeOverlay, Element3D, Element) from ...core.options import abbreviated_exception from ..plot import GenericElementPlot, GenericOverlayPlot -from ..util import dynamic_update +from ..util import dynamic_update, process_cmap from .plot import MPLPlot, mpl_rc_context from .util import wrap_formatter from distutils.version import LooseVersion @@ -480,6 +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.""") + clipping_colors = param.Dict(default={}, doc=""" Dictionary to specify colors for clipped values, allows setting color for NaN values and for values above and below @@ -506,6 +508,8 @@ class ColorbarPlot(ElementPlot): _colorbars = {} + _default_nan = '#8b8b8b' + def __init__(self, *args, **kwargs): super(ColorbarPlot, self).__init__(*args, **kwargs) self._cbar_extend = 'neither' @@ -593,23 +597,22 @@ def _norm_kwargs(self, element, ranges, opts, vdim, prefix=''): to be passed to matplotlib plot function. """ clim = opts.pop(prefix+'clims', None) + values = np.asarray(element.dimension_values(vdim)) if clim is None: - cs = element.dimension_values(vdim) - if not isinstance(cs, np.ndarray): - cs = np.array(cs) - if len(cs) and cs.dtype.kind in 'if': + if not isinstance(values, np.ndarray): + values = np.array(values) + if len(values) and values.dtype.kind in 'if': clim = ranges[vdim.name] if vdim.name in ranges else element.range(vdim) if self.logz: # Lower clim must be >0 when logz=True # Choose the maximum between the lowest non-zero value # and the overall range if clim[0] == 0: - vals = element.dimension_values(vdim) - clim = (vals[vals!=0].min(), clim[1]) + clim = (values[values!=0].min(), clim[1]) if self.symmetric: clim = -np.abs(clim).max(), np.abs(clim).max() else: - clim = (0, len(np.unique(cs))) + clim = (0, len(np.unique(values))-1) if self.logz: if self.symmetric: norm = mpl_colors.SymLogNorm(vmin=clim[0], vmax=clim[1], @@ -621,13 +624,14 @@ def _norm_kwargs(self, element, ranges, opts, vdim, prefix=''): opts[prefix+'vmax'] = clim[1] # Check whether the colorbar should indicate clipping - values = np.asarray(element.dimension_values(vdim)) if values.dtype.kind not in 'OSUM': + ncolors = self.color_levels try: el_min, el_max = np.nanmin(values), np.nanmax(values) except ValueError: el_min, el_max = -np.inf, np.inf else: + ncolors = clim[-1]+1 el_min, el_max = -np.inf, np.inf vmin = -np.inf if opts[prefix+'vmin'] is None else opts[prefix+'vmin'] vmax = np.inf if opts[prefix+'vmax'] is None else opts[prefix+'vmax'] @@ -639,16 +643,12 @@ 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') - if isinstance(cmap, list): - cmap = mpl_colors.ListedColormap(cmap) - elif isinstance(cmap, util.basestring): - cmap = copy.copy(plt.cm.get_cmap(cmap)) - else: - cmap = copy.copy(cmap) + cmap = opts.get(prefix+'cmap', 'viridis') colors = {} for k, val in self.clipping_colors.items(): - if isinstance(val, tuple): + if val == 'transparent': + colors[k] = {'color': 'w', 'alpha': 0} + elif isinstance(val, tuple): colors[k] = {'color': val[:3], 'alpha': val[3] if len(val) > 3 else 1} elif isinstance(val, util.basestring): @@ -658,6 +658,15 @@ def _norm_kwargs(self, element, ranges, opts, vdim, prefix=''): alpha = int(color[-2:], 16)/255. color = color[:-2] colors[k] = {'color': color, 'alpha': alpha} + + if not isinstance(cmap, mpl_colors.Colormap): + if isinstance(cmap, dict): + factors = np.unique(values) + palette = [cmap.get(f, colors.get('NaN', {'color': self._default_nan})['color']) + for f in factors] + else: + palette = process_cmap(cmap, ncolors) + cmap = mpl_colors.ListedColormap(palette) if 'max' in colors: cmap.set_over(**colors['max']) if 'min' in colors: cmap.set_under(**colors['min']) if 'NaN' in colors: cmap.set_bad(**colors['NaN']) diff --git a/holoviews/plotting/mpl/raster.py b/holoviews/plotting/mpl/raster.py index 0db4ea7526..2713910560 100644 --- a/holoviews/plotting/mpl/raster.py +++ b/holoviews/plotting/mpl/raster.py @@ -18,6 +18,8 @@ class RasterPlot(ColorbarPlot): Images by default but may be set to an explicit aspect ratio or to 'square'.""") + clipping_colors = param.Dict(default={'NaN': 'transparent'}) + colorbar = param.Boolean(default=False, doc=""" Whether to add a colorbar to the plot.""") @@ -98,6 +100,8 @@ def update_handles(self, key, axis, element, ranges, style): class QuadMeshPlot(ColorbarPlot): + clipping_colors = param.Dict(default={'NaN': 'transparent'}) + show_legend = param.Boolean(default=False, doc=""" Whether to show legend for the plot.""") diff --git a/holoviews/plotting/util.py b/holoviews/plotting/util.py index eec1d25abe..9cdc16d8b3 100644 --- a/holoviews/plotting/util.py +++ b/holoviews/plotting/util.py @@ -467,9 +467,7 @@ def map_colors(arr, crange, cmap, hex=True): arr = np.ma.array(arr, mask=np.logical_not(np.isfinite(arr))) arr = cmap(arr) if hex: - arr *= 255 - return ["#{0:02x}{1:02x}{2:02x}".format(*(int(v) for v in c[:-1])) - for c in arr] + return rgb2hex(arr) else: return arr @@ -478,29 +476,136 @@ def mplcmap_to_palette(cmap, ncolors=None): """ Converts a matplotlib colormap to palette of RGB hex strings." """ - from matplotlib.colors import Colormap + from matplotlib.colors import Colormap, ListedColormap + + ncolors = ncolors or 256 if not isinstance(cmap, Colormap): import matplotlib.cm as cm - cmap = cm.get_cmap(cmap) #choose any matplotlib colormap here - if ncolors: - return [rgb2hex(cmap(i)) for i in np.linspace(0, 1, ncolors)] - return [rgb2hex(m) for m in cmap(np.arange(cmap.N))] + # Alias bokeh Category cmaps with mpl tab cmaps + if cmap.startswith('Category'): + cmap = cmap.replace('Category', 'tab') + try: + cmap = cm.get_cmap(cmap) + except: + cmap = cm.get_cmap(cmap.lower()) + if isinstance(cmap, ListedColormap) and cmap.N > ncolors: + palette = [rgb2hex(c) for c in cmap(np.arange(cmap.N))] + if len(palette) != ncolors: + palette = [palette[int(v)] for v in np.linspace(0, len(palette)-1, ncolors)] + return palette + return [rgb2hex(c) for c in cmap(np.linspace(0, 1, ncolors))] def bokeh_palette_to_palette(cmap, ncolors=None): from bokeh import palettes + + # Handle categorical colormaps to avoid interpolation + categorical = ('accent', 'category', 'dark', 'colorblind', 'pastel', + 'set1', 'set2', 'set3', 'paired') + + reverse = cmap.endswith('_r') + ncolors = ncolors or 256 + + # Alias mpl tab cmaps with bokeh Category cmaps + if cmap.startswith('tab'): + cmap = cmap.replace('tab', 'Category') + if reverse: + cmap = cmap[:-2] + # Process as bokeh palette - palette = getattr(palettes, cmap, None) + palette = getattr(palettes, cmap, getattr(palettes, cmap.capitalize(), None)) if palette is None: raise ValueError("Supplied palette %s not found among bokeh palettes" % cmap) - elif isinstance(palette, dict): - if ncolors in palette: - palette = palette[ncolors] - else: - palette = sorted(palette.items())[-1][1] - if ncolors: - return [palette[i%len(palette)] for i in range(ncolors)] - return list(palette) + elif isinstance(palette, dict) and (cmap in palette or cmap.capitalize() in palette): + # Some bokeh palettes are doubly nested + palette = palette.get(cmap, palette.get(cmap.capitalize())) + + if isinstance(palette, dict): + if any(cat in cmap.lower() for cat in categorical): + palette = sorted(palette.items())[-1][1] + else: + if max(palette) > ncolors: + palette = palette[max(palette)] + else: + largest_factor = max([n for n in palette if ncolors%(n-1) == 0]) + palette = palette[largest_factor] + palette = polylinear_gradient(palette, ncolors) + if not reverse: + # Bokeh palettes are stored in reverse order + palette = palette[::-1] + elif callable(palette): + palette = palette(ncolors) + if len(palette) != ncolors: + palette = [palette[int(v)] for v in np.linspace(0, len(palette)-1, ncolors)] + return palette + + +def linear_gradient(start_hex, finish_hex, n=10): + """ + Interpolates the color gradient between to hex colors + """ + s = hex2rgb(start_hex) + f = hex2rgb(finish_hex) + gradient = [s] + for t in range(1, n): + curr_vector = [int(s[j] + (float(t)/(n-1))*(f[j]-s[j])) for j in range(3)] + gradient.append(curr_vector) + return [rgb2hex([c/255. for c in rgb]) for rgb in gradient] + + +def polylinear_gradient(colors, n): + """ + Interpolates the color gradients between a list of hex colors. + """ + n_out = int(float(n) / (len(colors)-1)) + gradient = linear_gradient(colors[0], colors[1], n_out) + + if len(colors) == len(gradient): + return gradient + + for col in range(1, len(colors) - 1): + next_colors = linear_gradient(colors[col], colors[col+1], n_out+1) + gradient += next_colors[1:] if len(next_colors) > 1 else next_colors + return gradient + + +def list_cmaps(provider='all'): + """ + List available colormaps by combining matplotlib colormaps and + bokeh palettes if available. May also be narrowed down to a + particular provider or list of providers. + """ + providers = ['matplotlib', 'bokeh', 'colorcet'] + if provider == 'all': + provider = providers + elif isinstance(provider, basestring): + if provider not in providers: + raise ValueError('Colormap provider %r not recognized, must ' + 'be one of %r' % (provider, providers)) + provider = [provider] + cmaps = [] + if 'matplotlib' in provider: + try: + import matplotlib.cm as cm + cmaps += [cmap for cmap in cm.cmap_d if not + (cmap.startswith('cet_') or # duplicates list below + cmap.startswith('Vega') or # deprecated in matplotlib=2.1 + cmap.startswith('spectral') )] # deprecated in matplotlib=2.1 + except: + pass + if 'bokeh' in provider: + try: + from bokeh import palettes + cmaps += list(palettes.all_palettes) + except: + pass + if 'colorcet' in provider: + try: + from colorcet import palette_n + cmaps += list(palette_n) + except: + pass + return sorted(unique_iterator(cmaps)) def process_cmap(cmap, ncolors=None): @@ -511,22 +616,30 @@ def process_cmap(cmap, ncolors=None): palette = [rgb2hex(c) if isinstance(c, tuple) else c for c in cmap.values] elif isinstance(cmap, list): palette = cmap + elif isinstance(cmap, basestring): + mpl_cmaps = list_cmaps('matplotlib') + bk_cmaps = list_cmaps('bokeh') + cet_cmaps = list_cmaps('colorcet') + if cmap in mpl_cmaps or cmap.lower() in mpl_cmaps: + palette = mplcmap_to_palette(cmap, ncolors) + elif cmap in bk_cmaps or cmap.capitalize() in bk_cmaps: + palette = bokeh_palette_to_palette(cmap, ncolors) + elif cmap in cet_cmaps: + from colorcet import palette_n + palette = palette_n[cmap] + else: + raise ValueError("Supplied cmap %s not found among matplotlib, " + "bokeh or colorcet colormaps." % cmap) else: try: - # Process as matplotlib colormap + # Try processing as matplotlib colormap palette = mplcmap_to_palette(cmap, ncolors) except: - try: - palette = bokeh_palette_to_palette(cmap, ncolors) - except: - if isinstance(cmap, basestring): - raise ValueError("Supplied cmap %s not found among " - "matplotlib or bokeh colormaps." % cmap) - palette = None + palette = None if not isinstance(palette, list): raise TypeError("cmap argument expects a list, Cycle or valid matplotlib " "colormap or bokeh palette, found %s." % cmap) - if ncolors: + if ncolors and len(palette) != ncolors: return [palette[i%len(palette)] for i in range(ncolors)] return palette @@ -595,6 +708,25 @@ def rgb2hex(rgb): return "#{0:02x}{1:02x}{2:02x}".format(*(int(v*255) for v in rgb)) +def hex2rgb(hex): + ''' "#FFFFFF" -> [255,255,255] ''' + # Pass 16 to the integer function for change of base + return [int(hex[i:i+2], 16) for i in range(1,6,2)] + + +COLOR_ALIASES = { + 'b': (0, 0, 1), + 'c': (0, 0.75, 0.75), + 'g': (0, 0.5, 0), + 'k': (0, 0, 0), + 'm': (0.75, 0, 0.75), + 'r': (1, 0, 0), + 'w': (1, 1, 1), + 'y': (0.75, 0.75, 0), + 'transparent': (0, 0, 0, 0) +} + + # linear_kryw_0_100_c71 (aka "fire"): # A perceptually uniform equivalent of matplotlib's "hot" colormap, from # http://peterkovesi.com/projects/colourmaps diff --git a/tests/plotting/bokeh/testelementplot.py b/tests/plotting/bokeh/testelementplot.py index 03427fe5e0..d2a0077cd5 100644 --- a/tests/plotting/bokeh/testelementplot.py +++ b/tests/plotting/bokeh/testelementplot.py @@ -94,3 +94,32 @@ def test_element_grid_options(self): self.assertEqual(plot.state.ygrid[0].grid_line_color, 'blue') self.assertEqual(plot.state.ygrid[0].grid_line_width, 1.5) self.assertEqual(plot.state.ygrid[0].bounds, (0.3, 0.7)) + + +class TestColorbarPlot(TestBokehPlot): + + def test_colormapper_symmetric(self): + img = Image(np.array([[0, 1], [2, 3]])).options(symmetric=True) + plot = bokeh_renderer.get_plot(img) + cmapper = plot.handles['color_mapper'] + self.assertEqual(cmapper.low, -3) + self.assertEqual(cmapper.high, 3) + + def test_colormapper_color_levels(self): + img = Image(np.array([[0, 1], [2, 3]])).options(color_levels=5) + plot = bokeh_renderer.get_plot(img) + cmapper = plot.handles['color_mapper'] + self.assertEqual(len(cmapper.palette), 5) + + def test_colormapper_transparent_nan(self): + img = Image(np.array([[0, 1], [2, 3]])).options(clipping_colors={'NaN': 'transparent'}) + plot = bokeh_renderer.get_plot(img) + cmapper = plot.handles['color_mapper'] + self.assertEqual(cmapper.nan_color, 'rgba(0, 0, 0, 0)') + + def test_colormapper_min_max_colors(self): + img = Image(np.array([[0, 1], [2, 3]])).options(clipping_colors={'min': 'red', 'max': 'blue'}) + plot = bokeh_renderer.get_plot(img) + cmapper = plot.handles['color_mapper'] + self.assertEqual(cmapper.low_color, 'red') + self.assertEqual(cmapper.high_color, 'blue') diff --git a/tests/plotting/bokeh/testpointplot.py b/tests/plotting/bokeh/testpointplot.py index 71071a8640..d048d84cd7 100644 --- a/tests/plotting/bokeh/testpointplot.py +++ b/tests/plotting/bokeh/testpointplot.py @@ -43,12 +43,12 @@ def test_points_color_selection_nonselection(self): vdims=['a', 'b']).opts(style=opts) plot = bokeh_renderer.get_plot(points) glyph_renderer = plot.handles['glyph_renderer'] - self.assertEqual(glyph_renderer.glyph.fill_color, '#008000') - self.assertEqual(glyph_renderer.glyph.line_color, '#008000') - self.assertEqual(glyph_renderer.selection_glyph.fill_color, '#FF0000') - self.assertEqual(glyph_renderer.selection_glyph.line_color, '#FF0000') - self.assertEqual(glyph_renderer.nonselection_glyph.fill_color, '#0000FF') - self.assertEqual(glyph_renderer.nonselection_glyph.line_color, '#0000FF') + self.assertEqual(glyph_renderer.glyph.fill_color, 'green') + self.assertEqual(glyph_renderer.glyph.line_color, 'green') + self.assertEqual(glyph_renderer.selection_glyph.fill_color, 'red') + self.assertEqual(glyph_renderer.selection_glyph.line_color, 'red') + self.assertEqual(glyph_renderer.nonselection_glyph.fill_color, 'blue') + self.assertEqual(glyph_renderer.nonselection_glyph.line_color, 'blue') def test_points_alpha_selection_nonselection(self): opts = dict(alpha=0.8, selection_alpha=1.0, nonselection_alpha=0.2) diff --git a/tests/plotting/matplotlib/testelementplot.py b/tests/plotting/matplotlib/testelementplot.py new file mode 100644 index 0000000000..6608a67413 --- /dev/null +++ b/tests/plotting/matplotlib/testelementplot.py @@ -0,0 +1,34 @@ +import numpy as np + +from holoviews.element import Image + +from .testplot import TestMPLPlot, mpl_renderer + + +class TestColorbarPlot(TestMPLPlot): + + def test_colormapper_symmetric(self): + img = Image(np.array([[0, 1], [2, 3]])).options(symmetric=True) + plot = mpl_renderer.get_plot(img) + artist = plot.handles['artist'] + self.assertEqual(artist.get_clim(), (-3, 3)) + + def test_colormapper_color_levels(self): + img = Image(np.array([[0, 1], [2, 3]])).options(color_levels=5) + plot = mpl_renderer.get_plot(img) + artist = plot.handles['artist'] + self.assertEqual(len(artist.cmap.colors), 5) + + def test_colormapper_transparent_nan(self): + img = Image(np.array([[0, 1], [2, 3]])).options(clipping_colors={'NaN': 'transparent'}) + plot = mpl_renderer.get_plot(img) + cmap = plot.handles['artist'].cmap + self.assertEqual(cmap._rgba_bad, (1.0, 1.0, 1.0, 0)) + + def test_colormapper_min_max_colors(self): + img = Image(np.array([[0, 1], [2, 3]])).options(clipping_colors={'min': 'red', 'max': 'blue'}) + plot = mpl_renderer.get_plot(img) + cmap = plot.handles['artist'].cmap + print(dir(cmap)) + self.assertEqual(cmap._rgba_under, (1.0, 0, 0, 1)) + self.assertEqual(cmap._rgba_over, (0, 0, 1.0, 1)) diff --git a/tests/plotting/testplotutils.py b/tests/plotting/testplotutils.py index 351b6f918b..3a535d8fcd 100644 --- a/tests/plotting/testplotutils.py +++ b/tests/plotting/testplotutils.py @@ -14,7 +14,8 @@ from holoviews.operation import operation from holoviews.plotting.util import ( compute_overlayable_zorders, get_min_distance, process_cmap, - initialize_dynamic, split_dmap_overlay, _get_min_distance_numpy) + initialize_dynamic, split_dmap_overlay, _get_min_distance_numpy, + bokeh_palette_to_palette, mplcmap_to_palette) from holoviews.streams import PointerX try: @@ -429,23 +430,6 @@ def test_dmap_overlay_linked_operation_mul_dmap_element_ndoverlay(self): class TestPlotColorUtils(ComparisonTestCase): - def test_process_cmap_mpl(self): - colors = process_cmap('Greys', 3) - self.assertEqual(colors, ['#ffffff', '#959595', '#000000']) - - def test_process_cmap_instance_mpl(self): - try: - from matplotlib.cm import get_cmap - except: - raise SkipTest("Matplotlib needed to test matplotlib colormap instances") - cmap = get_cmap('Greys') - colors = process_cmap(cmap, 3) - self.assertEqual(colors, ['#ffffff', '#959595', '#000000']) - - def test_process_cmap_bokeh(self): - colors = process_cmap('Category20', 3) - self.assertEqual(colors, ['#1f77b4', '#aec7e8', '#ff7f0e']) - def test_process_cmap_list_cycle(self): colors = process_cmap(['#ffffff', '#959595', '#000000'], 4) self.assertEqual(colors, ['#ffffff', '#959595', '#000000', '#ffffff']) @@ -463,6 +447,66 @@ def test_process_cmap_invalid_type(self): process_cmap({'A', 'B', 'C'}, 3) +class TestMPLColormapUtils(ComparisonTestCase): + + def setUp(self): + try: + import matplotlib.cm # noqa + except: + raise SkipTest("Matplotlib needed to test matplotlib colormap instances") + + def test_mpl_colormap_name_palette(self): + colors = process_cmap('Greys', 3) + self.assertEqual(colors, ['#ffffff', '#959595', '#000000']) + + def test_mpl_colormap_instance(self): + from matplotlib.cm import get_cmap + cmap = get_cmap('Greys') + colors = process_cmap(cmap, 3) + self.assertEqual(colors, ['#ffffff', '#959595', '#000000']) + + def test_mpl_colormap_categorical(self): + colors = mplcmap_to_palette('Category20', 3) + self.assertEqual(colors, ['#1f77b4', '#c5b0d5', '#9edae5']) + + def test_mpl_colormap_sequential(self): + colors = mplcmap_to_palette('RdBu', 3) + self.assertEqual(colors, ['#67001f', '#f6f6f6', '#053061']) + + def test_mpl_colormap_perceptually_uniform(self): + colors = mplcmap_to_palette('viridis', 4) + self.assertEqual(colors, ['#440154', '#30678d', '#35b778', '#fde724']) + + +class TestBokehPaletteUtils(ComparisonTestCase): + + def setUp(self): + try: + import bokeh.palettes # noqa + except: + raise SkipTest('Bokeh required to test bokeh palette utilities') + + def test_bokeh_palette_categorical_palettes_not_interpolated(self): + # Ensure categorical palettes are not expanded + categorical = ('accent', 'category20', 'dark2', 'colorblind', 'pastel1', + 'pastel2', 'set1', 'set2', 'set3', 'paired') + for cat in categorical: + self.assertTrue(len(set(bokeh_palette_to_palette(cat))) <= 20) + + def test_bokeh_palette_categorical(self): + colors = bokeh_palette_to_palette('Category20', 3) + self.assertEqual(colors, ['#1f77b4', '#c5b0d5', '#9edae5']) + + def test_bokeh_palette_sequential(self): + colors = bokeh_palette_to_palette('RdBu', 3) + self.assertEqual(colors, ['#67001f', '#f7f7f7', '#053061']) + + def test_bokeh_palette_perceptually_uniform(self): + colors = bokeh_palette_to_palette('viridis', 4) + self.assertEqual(colors, ['#440154', '#30678D', '#35B778', '#FDE724']) + + + class TestPlotUtils(ComparisonTestCase): def test_get_min_distance_float32_type(self):