From 89ca9012907afac15080accff34d70b79d39f12f Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 5 Oct 2018 16:48:54 +0100 Subject: [PATCH] Add tick formatter plot options (#3042) --- examples/user_guide/Plotting_with_Bokeh.ipynb | 27 +----- examples/user_guide/Styling_Plots.ipynb | 64 +++++++++++++++ holoviews/plotting/bokeh/element.py | 30 ++++++- holoviews/plotting/mpl/element.py | 30 +++++-- holoviews/plotting/mpl/raster.py | 3 + tests/plotting/bokeh/testelementplot.py | 48 ++++++++++- tests/plotting/matplotlib/testelementplot.py | 82 ++++++++++++++++++- 7 files changed, 245 insertions(+), 39 deletions(-) diff --git a/examples/user_guide/Plotting_with_Bokeh.ipynb b/examples/user_guide/Plotting_with_Bokeh.ipynb index 25e1df04d6..7a3ef121f5 100644 --- a/examples/user_guide/Plotting_with_Bokeh.ipynb +++ b/examples/user_guide/Plotting_with_Bokeh.ipynb @@ -144,32 +144,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### Controlling axes" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Bokeh provides a variety of options to control the axes. Here we provide a quick overview of linked plots for the same data displayed differently by applying log axes, disabling axes, rotating ticks, specifying the number of ticks, and supplying an explicit list of ticks." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "points = hv.Points(np.exp(xs)) \n", - "axes_opts = [('Plain', {}),\n", - " ('Log', {'logy': True}),\n", - " ('None', {'yaxis': None}),\n", - " ('Rotate', {'xrotation': 90}),\n", - " ('N Ticks', {'xticks': 3}),\n", - " ('List Ticks', {'xticks': [0, 20, 50, 90]})]\n", - "\n", - "hv.Layout([points.relabel(group=group).options(**opts)\n", - " for group, opts in axes_opts]).cols(3)" + "## Controlling axes" ] }, { diff --git a/examples/user_guide/Styling_Plots.ipynb b/examples/user_guide/Styling_Plots.ipynb index b1a9d5cc61..5288963369 100644 --- a/examples/user_guide/Styling_Plots.ipynb +++ b/examples/user_guide/Styling_Plots.ipynb @@ -401,6 +401,70 @@ "\n", "categorical_points.sort('Category').options(color_index='Category', cmap=explicit_mapping, size=5)" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Controlling axes" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "A number of options to control the axes are shared across backend. Here we provide a quick overview of linked plots for the same data displayed differently by applying log axes, disabling axes, rotating ticks, specifying the number of ticks, and supplying an explicit list of ticks." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "points = hv.Points(np.exp(xs)) \n", + "axes_opts = [('Plain', {}),\n", + " ('Log', {'logy': True}),\n", + " ('None', {'yaxis': None}),\n", + " ('Rotate', {'xrotation': 90}),\n", + " ('N Ticks', {'xticks': 3}),\n", + " ('List Ticks', {'xticks': [0, 20, 50, 90]})]\n", + "\n", + "hv.Layout([points.relabel(group=group).options(**opts)\n", + " for group, opts in axes_opts]).cols(3)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Tick formatters" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Tick formatting works very differently in different backends, however the ``xformatter`` and ``yformatter`` options try to minimize these differences. Tick formatters may be defined in one of three formats:\n", + "\n", + "* A classic format string such as ``'%d'``, ``'%.3f'`` or ``'%d'`` which may also contain other characters (``'$%.2f'``)\n", + "* A function which will be compiled to JS using flexx (if installed) when using bokeh\n", + "* A ``bokeh.models.TickFormatter`` in bokeh and a ``matplotlib.ticker.Formatter`` instance in matplotlib\n", + "\n", + "Here is a small example demonstrating how to use the string format and function approaches:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def formatter(value):\n", + " return str(value) + ' %'\n", + "\n", + "points.options(xformatter=formatter, yformatter='$%.2f')" + ] } ], "metadata": { diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index c9c2b62b0d..afb61b2924 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -1,4 +1,5 @@ import warnings +from types import FunctionType import param import numpy as np @@ -6,7 +7,8 @@ import bokeh.plotting from bokeh.core.properties import value from bokeh.models import (HoverTool, Renderer, Range1d, DataRange1d, Title, - FactorRange, FuncTickFormatter, Tool, Legend) + FactorRange, FuncTickFormatter, Tool, Legend, + TickFormatter, PrintfTickFormatter) from bokeh.models.tickers import Ticker, BasicTicker, FixedTicker, LogTicker from bokeh.models.widgets import Panel, Tabs from bokeh.models.mappers import LinearColorMapper @@ -107,6 +109,14 @@ class ElementPlot(BokehPlot, GenericElementPlot): The toolbar location, must be one of 'above', 'below', 'left', 'right', None.""") + xformatter = param.ClassSelector( + default=None, class_=(util.basestring, TickFormatter, FunctionType), doc=""" + Formatter for ticks along the x-axis.""") + + yformatter = param.ClassSelector( + default=None, class_=(util.basestring, TickFormatter, FunctionType), doc=""" + Formatter for ticks along the x-axis.""") + _categorical = False # Declares the default types for continuous x- and y-axes @@ -421,7 +431,23 @@ def _axis_properties(self, axis, key, plot, dimension=None, else: axis_props['ticker'] = FixedTicker(ticks=ticker) - if FuncTickFormatter is not None and ax_mapping and dimension: + formatter = self.xformatter if axis == 'x' else self.yformatter + if formatter: + if isinstance(formatter, TickFormatter): + pass + elif isinstance(formatter, FunctionType): + msg = ('%sformatter could not be ' + 'converted to tick formatter. ' % axis) + jsfunc = py2js_tickformatter(formatter, msg) + if jsfunc: + formatter = FuncTickFormatter(code=jsfunc) + else: + formatter = None + else: + formatter = PrintfTickFormatter(format=formatter) + if formatter is not None: + axis_props['formatter'] = formatter + elif FuncTickFormatter is not None and ax_mapping and dimension: formatter = None if dimension.value_format: formatter = dimension.value_format diff --git a/holoviews/plotting/mpl/element.py b/holoviews/plotting/mpl/element.py index 963e5ed800..1acaf5ade7 100644 --- a/holoviews/plotting/mpl/element.py +++ b/holoviews/plotting/mpl/element.py @@ -1,4 +1,5 @@ import math +from types import FunctionType import param import numpy as np @@ -40,6 +41,18 @@ class ElementPlot(GenericElementPlot, MPLPlot): logz = param.Boolean(default=False, doc=""" Whether to apply log scaling to the y-axis of the Chart.""") + xformatter = param.ClassSelector( + default=None, class_=(util.basestring, ticker.Formatter, FunctionType), doc=""" + Formatter for ticks along the x-axis.""") + + yformatter = param.ClassSelector( + default=None, class_=(util.basestring, ticker.Formatter, FunctionType), doc=""" + Formatter for ticks along the y-axis.""") + + zformatter = param.ClassSelector( + default=None, class_=(util.basestring, ticker.Formatter, FunctionType), doc=""" + Formatter for ticks along the z-axis.""") + zaxis = param.Boolean(default=True, doc=""" Whether to display the z-axis.""") @@ -111,7 +124,7 @@ def _finalize_axis(self, key, element=None, title=None, dimensions=None, ranges= self._subplot_label(axis) # Apply axis options if axes are enabled - if element and not any(not sp._has_axes for sp in [self] + subplots): + if element is not None and not any(not sp._has_axes for sp in [self] + subplots): # Set axis labels if dimensions: self._set_labels(axis, dimensions, xlabel, ylabel, zlabel) @@ -164,13 +177,13 @@ def _finalize_ticks(self, axis, dimensions, xticks, yticks, zticks): # Tick formatting if xdim: - self._set_axis_formatter(axis.xaxis, xdim) + self._set_axis_formatter(axis.xaxis, xdim, self.xformatter) if ydim: - self._set_axis_formatter(axis.yaxis, ydim) + self._set_axis_formatter(axis.yaxis, ydim, self.yformatter) if self.projection == '3d': zdim = dimensions[2] if ndims > 2 else None - if zdim: - self._set_axis_formatter(axis.zaxis, zdim) + if zdim or self.zformatter is not None: + self._set_axis_formatter(axis.zaxis, zdim, self.zformatter) xticks = xticks if xticks else self.xticks self._set_axis_ticks(axis.xaxis, xticks, log=self.logx, @@ -222,13 +235,14 @@ def _set_labels(self, axes, dimensions, xlabel=None, ylabel=None, zlabel=None): axes.set_zlabel(zlabel, **self._fontsize('zlabel')) - def _set_axis_formatter(self, axis, dim): + def _set_axis_formatter(self, axis, dim, formatter): """ Set axis formatter based on dimension formatter. """ if isinstance(dim, list): dim = dim[0] - formatter = None - if dim.value_format: + if formatter is not None: + pass + elif dim.value_format: formatter = dim.value_format elif dim.type in dim.type_formatters: formatter = dim.type_formatters[dim.type] diff --git a/holoviews/plotting/mpl/raster.py b/holoviews/plotting/mpl/raster.py index d162a0aef3..431f171a88 100644 --- a/holoviews/plotting/mpl/raster.py +++ b/holoviews/plotting/mpl/raster.py @@ -180,10 +180,13 @@ class RasterGridPlot(GridPlot, OverlayPlot): ylim = param.Parameter(precedence=-1) zlim = param.Parameter(precedence=-1) xticks = param.Parameter(precedence=-1) + xformatter = param.Parameter(precedence=-1) yticks = param.Parameter(precedence=-1) + yformatter = param.Parameter(precedence=-1) zticks = param.Parameter(precedence=-1) zaxis = param.Parameter(precedence=-1) zrotation = param.Parameter(precedence=-1) + zformatter = param.Parameter(precedence=-1) def __init__(self, layout, keys=None, dimensions=None, create_axes=False, ranges=None, diff --git a/tests/plotting/bokeh/testelementplot.py b/tests/plotting/bokeh/testelementplot.py index 34ecb7c8c6..f5452f92a8 100644 --- a/tests/plotting/bokeh/testelementplot.py +++ b/tests/plotting/bokeh/testelementplot.py @@ -11,7 +11,7 @@ try: from bokeh.document import Document - from bokeh.models import FuncTickFormatter + from bokeh.models import FuncTickFormatter, PrintfTickFormatter, NumeralTickFormatter except: pass @@ -24,6 +24,50 @@ def test_element_show_frame_disabled(self): plot = bokeh_renderer.get_plot(curve).state self.assertEqual(plot.outline_line_alpha, 0) + def test_element_xformatter_string(self): + curve = Curve(range(10)).options(xformatter='%d') + plot = bokeh_renderer.get_plot(curve) + xaxis = plot.handles['xaxis'] + self.assertIsInstance(xaxis.formatter, PrintfTickFormatter) + self.assertEqual(xaxis.formatter.format, '%d') + + def test_element_yformatter_string(self): + curve = Curve(range(10)).options(yformatter='%d') + plot = bokeh_renderer.get_plot(curve) + yaxis = plot.handles['yaxis'] + self.assertIsInstance(yaxis.formatter, PrintfTickFormatter) + self.assertEqual(yaxis.formatter.format, '%d') + + def test_element_xformatter_function(self): + def formatter(value): + return str(value) + ' %' + curve = Curve(range(10)).options(xformatter=formatter) + plot = bokeh_renderer.get_plot(curve) + xaxis = plot.handles['xaxis'] + self.assertIsInstance(xaxis.formatter, FuncTickFormatter) + + def test_element_yformatter_function(self): + def formatter(value): + return str(value) + ' %' + curve = Curve(range(10)).options(yformatter=formatter) + plot = bokeh_renderer.get_plot(curve) + yaxis = plot.handles['yaxis'] + self.assertIsInstance(yaxis.formatter, FuncTickFormatter) + + def test_element_xformatter_instance(self): + formatter = NumeralTickFormatter() + curve = Curve(range(10)).options(xformatter=formatter) + plot = bokeh_renderer.get_plot(curve) + xaxis = plot.handles['xaxis'] + self.assertIs(xaxis.formatter, formatter) + + def test_element_yformatter_instance(self): + formatter = NumeralTickFormatter() + curve = Curve(range(10)).options(yformatter=formatter) + plot = bokeh_renderer.get_plot(curve) + yaxis = plot.handles['yaxis'] + self.assertIs(yaxis.formatter, formatter) + def test_empty_element_visibility(self): curve = Curve([]) plot = bokeh_renderer.get_plot(curve) @@ -151,7 +195,7 @@ def test_colormapper_symmetric(self): cmapper = plot.handles['color_mapper'] self.assertEqual(cmapper.low, -3) self.assertEqual(cmapper.high, 3) - + def test_colormapper_color_levels(self): cmap = process_cmap('viridis', provider='bokeh') img = Image(np.array([[0, 1], [2, 3]])).options(color_levels=5, cmap=cmap) diff --git a/tests/plotting/matplotlib/testelementplot.py b/tests/plotting/matplotlib/testelementplot.py index 0f5afa288e..2bce6b31d7 100644 --- a/tests/plotting/matplotlib/testelementplot.py +++ b/tests/plotting/matplotlib/testelementplot.py @@ -1,11 +1,15 @@ import numpy as np from holoviews.core.spaces import DynamicMap -from holoviews.element import Image, Curve, Scatter +from holoviews.element import Image, Curve, Scatter, Scatter3D from holoviews.streams import Stream from .testplot import TestMPLPlot, mpl_renderer +try: + from matplotlib.ticker import FormatStrFormatter, FuncFormatter, PercentFormatter +except: + pass class TestElementPlot(TestMPLPlot): @@ -17,6 +21,82 @@ def test_stream_cleanup(self): plot.cleanup() self.assertFalse(bool(stream._subscribers)) + def test_element_xformatter_string(self): + curve = Curve(range(10)).options(xformatter='%d') + plot = mpl_renderer.get_plot(curve) + xaxis = plot.handles['axis'].xaxis + xformatter = xaxis.get_major_formatter() + self.assertIsInstance(xformatter, FormatStrFormatter) + self.assertEqual(xformatter.fmt, '%d') + + def test_element_yformatter_string(self): + curve = Curve(range(10)).options(yformatter='%d') + plot = mpl_renderer.get_plot(curve) + yaxis = plot.handles['axis'].yaxis + yformatter = yaxis.get_major_formatter() + self.assertIsInstance(yformatter, FormatStrFormatter) + self.assertEqual(yformatter.fmt, '%d') + + def test_element_zformatter_string(self): + curve = Scatter3D([]).options(zformatter='%d') + plot = mpl_renderer.get_plot(curve) + zaxis = plot.handles['axis'].zaxis + zformatter = zaxis.get_major_formatter() + self.assertIsInstance(zformatter, FormatStrFormatter) + self.assertEqual(zformatter.fmt, '%d') + + def test_element_xformatter_function(self): + def formatter(value): + return str(value) + ' %' + curve = Curve(range(10)).options(xformatter=formatter) + plot = mpl_renderer.get_plot(curve) + xaxis = plot.handles['axis'].xaxis + xformatter = xaxis.get_major_formatter() + self.assertIsInstance(xformatter, FuncFormatter) + + def test_element_yformatter_function(self): + def formatter(value): + return str(value) + ' %' + curve = Curve(range(10)).options(yformatter=formatter) + plot = mpl_renderer.get_plot(curve) + yaxis = plot.handles['axis'].yaxis + yformatter = yaxis.get_major_formatter() + self.assertIsInstance(yformatter, FuncFormatter) + + def test_element_zformatter_function(self): + def formatter(value): + return str(value) + ' %' + curve = Scatter3D([]).options(zformatter=formatter) + plot = mpl_renderer.get_plot(curve) + zaxis = plot.handles['axis'].zaxis + zformatter = zaxis.get_major_formatter() + self.assertIsInstance(zformatter, FuncFormatter) + + def test_element_xformatter_instance(self): + formatter = PercentFormatter() + curve = Curve(range(10)).options(xformatter=formatter) + plot = mpl_renderer.get_plot(curve) + xaxis = plot.handles['axis'].xaxis + xformatter = xaxis.get_major_formatter() + self.assertIs(xformatter, formatter) + + def test_element_yformatter_instance(self): + formatter = PercentFormatter() + curve = Curve(range(10)).options(yformatter=formatter) + plot = mpl_renderer.get_plot(curve) + yaxis = plot.handles['axis'].yaxis + yformatter = yaxis.get_major_formatter() + self.assertIs(yformatter, formatter) + + def test_element_zformatter_instance(self): + formatter = PercentFormatter() + curve = Scatter3D([]).options(zformatter=formatter) + plot = mpl_renderer.get_plot(curve) + zaxis = plot.handles['axis'].zaxis + zformatter = zaxis.get_major_formatter() + self.assertIs(zformatter, formatter) + + class TestColorbarPlot(TestMPLPlot):