diff --git a/holoviews/plotting/bokeh/chart.py b/holoviews/plotting/bokeh/chart.py index 24dbf29161..2149116b3e 100644 --- a/holoviews/plotting/bokeh/chart.py +++ b/holoviews/plotting/bokeh/chart.py @@ -19,7 +19,8 @@ from .element import (ElementPlot, ColorbarPlot, LegendPlot, line_properties, fill_properties) from .path import PathPlot, PolygonPlot -from .util import get_cmap, mpl_to_bokeh, update_plot, rgb2hex, bokeh_version +from .util import (get_cmap, mpl_to_bokeh, update_plot, rgb2hex, + bokeh_version, expand_batched_style, filter_batched_data) class PointPlot(LegendPlot, ColorbarPlot): @@ -46,10 +47,11 @@ class PointPlot(LegendPlot, ColorbarPlot): Function applied to size values before applying scaling, to remove values lower than zero.""") - style_opts = (['cmap', 'palette', 'marker', 'size', 's'] + + style_opts = (['cmap', 'palette', 'marker', 'size'] + line_properties + fill_properties) _plot_methods = dict(single='scatter', batched='scatter') + _batched_style_opts = line_properties + fill_properties + ['size'] def _get_size_data(self, element, ranges, style): data, mapping = {}, {} @@ -101,26 +103,28 @@ def get_batched_data(self, element, ranges=None, empty=False): zorders = self._updated_zorders(element) styles = self.lookup_options(element.last, 'style') styles = styles.max_cycles(len(self.ordering)) - for (key, el), zorder in zip(element.data.items(), zorders): self.set_param(**self.lookup_options(el, 'plot').options) eldata, elmapping = self.get_data(el, ranges, empty) for k, eld in eldata.items(): data[k].append(eld) - nvals = len(data[k][-1]) - if 'color' not in elmapping: - val = styles[zorder].get('color') - elmapping['color'] = {'field': 'color'} - if isinstance(val, tuple): - val = rgb2hex(val) - data['color'].append([val]*nvals) + # Apply static styles + nvals = len(list(eldata.values())[0]) + style = styles[zorder] + sdata, smapping = expand_batched_style(style, self._batched_style_opts, + elmapping, nvals) + elmapping.update(smapping) + for k, v in sdata.items(): + data[k].append(v) if any(isinstance(t, HoverTool) for t in self.state.tools): for dim, k in zip(element.dimensions(), key): sanitized = dimension_sanitizer(dim.name) data[sanitized].append([k]*nvals) + data = {k: np.concatenate(v) for k, v in data.items()} + filter_batched_data(data, elmapping) return data, elmapping @@ -223,7 +227,7 @@ class CurvePlot(ElementPlot): style_opts = line_properties _plot_methods = dict(single='line', batched='multi_line') - _mapping = {p: p for p in ['xs', 'ys', 'color', 'line_alpha']} + _batched_style_opts = line_properties def get_data(self, element, ranges=None, empty=False): if 'steps' in self.interpolation: @@ -248,7 +252,6 @@ def _hover_opts(self, element): def get_batched_data(self, overlay, ranges=None, empty=False): data = defaultdict(list) - opts = ['color', 'line_alpha', 'line_color'] zorders = self._updated_zorders(overlay) styles = self.lookup_options(overlay.last, 'style') @@ -259,23 +262,23 @@ def get_batched_data(self, overlay, ranges=None, empty=False): for k, eld in eldata.items(): data[k].append(eld) - # Add options + # Apply static styles style = styles[zorder] - for opt in opts: - if opt not in style: - continue - val = style[opt] - if opt == 'color' and isinstance(val, tuple): - val = rgb2hex(val) - data[opt].append([val]) + sdata, smapping = expand_batched_style(style, self._batched_style_opts, + elmapping, nvals=1) + elmapping.update(smapping) + for k, v in sdata.items(): + data[k].append(v[0]) for d, k in zip(overlay.kdims, key): sanitized = dimension_sanitizer(d.name) - data[sanitized].append([k]) + data[sanitized].append(k) data = {opt: vals for opt, vals in data.items() if not any(v is None for v in vals)} - return data, dict(xs=elmapping['x'], ys=elmapping['y'], - **{o: o for o in opts if o in data}) + mapping = {{'x': 'xs', 'y': 'ys'}.get(k, k): v + for k, v in elmapping.items()} + filter_batched_data(data, elmapping) + return data, mapping class AreaPlot(PolygonPlot): diff --git a/holoviews/plotting/bokeh/element.py b/holoviews/plotting/bokeh/element.py index 736570996e..42eb63cf06 100644 --- a/holoviews/plotting/bokeh/element.py +++ b/holoviews/plotting/bokeh/element.py @@ -643,8 +643,12 @@ def _update_glyphs(self, renderer, properties, mapping, glyph): if glyph_prop and ('fill_'+prop not in glyph_props or gtype): glyph_props['fill_'+prop] = glyph_prop - glyph_props.update({k[len(gtype):]: v for k, v in glyph_props.items() - if k.startswith(gtype)}) + props = {k[len(gtype):]: v for k, v in glyph_props.items() + if k.startswith(gtype)} + if self.batched: + glyph_props = dict(props, **glyph_props) + else: + glyph_props.update(props) filtered = {k: v for k, v in glyph_props.items() if k in allowed_properties} glyph.update(**filtered) diff --git a/holoviews/plotting/bokeh/path.py b/holoviews/plotting/bokeh/path.py index 5f70048dc5..d8a5ca79c1 100644 --- a/holoviews/plotting/bokeh/path.py +++ b/holoviews/plotting/bokeh/path.py @@ -8,7 +8,8 @@ from ...core import util from ..util import map_colors from .element import ElementPlot, ColorbarPlot, line_properties, fill_properties -from .util import get_cmap, rgb2hex +from .util import (get_cmap, rgb2hex, expand_batched_style, + filter_batched_data) class PathPlot(ElementPlot): @@ -16,9 +17,10 @@ class PathPlot(ElementPlot): show_legend = param.Boolean(default=False, doc=""" Whether to show legend for the plot.""") - style_opts = ['color'] + line_properties + style_opts = line_properties _plot_methods = dict(single='multi_line', batched='multi_line') _mapping = dict(xs='xs', ys='ys') + _batched_style_opts = line_properties def _hover_opts(self, element): if self.batched: @@ -45,19 +47,26 @@ def get_batched_data(self, element, ranges=None, empty=False): eldata, elmapping = self.get_data(el, ranges, empty) for k, eld in eldata.items(): data[k].extend(eld) - val = styles[zorder].get('color') - if val: - elmapping['line_color'] = 'color' - if isinstance(val, tuple): - val = rgb2hex(val) - data['color'] += [val for _ in range(len(list(eldata.values())[0]))] + + # Apply static styles + nvals = len(list(eldata.values())[0]) + style = styles[zorder] + sdata, smapping = expand_batched_style(style, self._batched_style_opts, + elmapping, nvals) + elmapping.update(smapping) + for k, v in sdata.items(): + data[k].extend(list(v)) + + filter_batched_data(data, elmapping) return data, elmapping class PolygonPlot(ColorbarPlot, PathPlot): - style_opts = ['color', 'cmap', 'palette'] + line_properties + fill_properties + style_opts = ['cmap', 'palette'] + line_properties + fill_properties _plot_methods = dict(single='patches', batched='patches') + _style_opts = ['color', 'cmap', 'palette'] + line_properties + fill_properties + _batched_style_opts = line_properties + fill_properties def _hover_opts(self, element): if self.batched: @@ -90,25 +99,3 @@ def get_data(self, element, ranges=None, empty=False): data[dim_name] = [element.level for _ in range(len(xs))] return data, mapping - - - def get_batched_data(self, element, ranges=None, empty=False): - data = defaultdict(list) - - zorders = self._updated_zorders(element) - styles = self.lookup_options(element.last, 'style') - styles = styles.max_cycles(len(self.ordering)) - - for (key, el), zorder in zip(element.data.items(), zorders): - self.overlay_dims = dict(zip(element.kdims, key)) - eldata, elmapping = self.get_data(el, ranges, empty) - for k, eld in eldata.items(): - data[k].extend(eld) - if 'color' not in elmapping: - val = styles[zorder].get('color') - elmapping['color'] = 'color' - if isinstance(val, tuple): - val = rgb2hex(val) - data['color'] += [val for _ in range(len(eldata['xs']))] - - return data, elmapping diff --git a/holoviews/plotting/bokeh/util.py b/holoviews/plotting/bokeh/util.py index a396cd1530..07f3f7f67f 100644 --- a/holoviews/plotting/bokeh/util.py +++ b/holoviews/plotting/bokeh/util.py @@ -27,6 +27,7 @@ from ...core.options import abbreviated_exception from ...core.overlay import Overlay +from ...core.util import basestring from ..util import dim_axis_label @@ -547,3 +548,61 @@ def get_tab_title(key, frame, overlay): title = ' | '.join([d.pprint_value_string(k) for d, k in zip(overlay.kdims, key)]) return title + + +def expand_batched_style(style, opts, mapping, nvals): + """ + Computes styles applied to a batched plot by iterating over the + supplied list of style options and expanding any options found in + the supplied style dictionary returning a data and mapping defining + the data that should be added to the ColumnDataSource. + """ + opts = sorted(opts, key=lambda x: x in ['color', 'alpha']) + applied_styles = set(mapping) + style_data, style_mapping = {}, {} + for opt in opts: + if 'color' in opt: + alias = 'color' + elif 'alpha' in opt: + alias = 'alpha' + else: + alias = None + if opt not in style: + continue + elif opt == alias: + if alias in applied_styles: + continue + elif 'line_'+alias in applied_styles: + if 'fill_'+alias not in opts: + continue + opt = 'fill_'+alias + val = style[alias] + elif 'fill_'+alias in applied_styles: + opt = 'line_'+alias + val = style[alias] + else: + val = style[alias] + else: + val = style[opt] + style_mapping[opt] = {'field': opt} + applied_styles.add(opt) + if 'color' in opt and isinstance(val, tuple): + val = rgb2hex(val) + style_data[opt] = [val]*nvals + return style_data, style_mapping + + +def filter_batched_data(data, mapping): + """ + Iterates over the data and mapping for a ColumnDataSource and + replaces columns with repeating values with scalar + """ + for k, v in list(mapping.items()): + if isinstance(v, dict) and 'field' in v: + v = v['field'] + elif not isinstance(v, basestring): + continue + values = data[v] + if len(np.unique(values)) == 1: + mapping[k] = values[0] + del data[v] diff --git a/tests/testbokehutils.py b/tests/testbokehutils.py new file mode 100644 index 0000000000..80defe3049 --- /dev/null +++ b/tests/testbokehutils.py @@ -0,0 +1,85 @@ +from collections import defaultdict +from unittest import SkipTest + +import numpy as np + +from holoviews.core import Store +from holoviews.element.comparison import ComparisonTestCase +from holoviews.element import Curve, Points, Path + +try: + from holoviews.plotting.bokeh.util import ( + bokeh_version, expand_batched_style, filter_batched_data + ) + bokeh_renderer = Store.renderers['bokeh'] +except: + bokeh_renderer = None + + +class TestBokehUtilsInstantiation(ComparisonTestCase): + + def setUp(self): + if not bokeh_renderer: + raise SkipTest("Bokeh required to test plot instantiation") + + def test_expand_style_opts_simple(self): + style = {'line_width': 3} + opts = ['line_width'] + data, mapping = expand_batched_style(style, opts, {}, nvals=3) + self.assertEqual(data['line_width'], [3, 3, 3]) + self.assertEqual(mapping, {'line_width': {'field': 'line_width'}}) + + def test_expand_style_opts_multiple(self): + style = {'line_color': 'red', 'line_width': 4} + opts = ['line_color', 'line_width'] + data, mapping = expand_batched_style(style, opts, {}, nvals=3) + self.assertEqual(data['line_color'], ['red', 'red', 'red']) + self.assertEqual(data['line_width'], [4, 4, 4]) + self.assertEqual(mapping, {'line_color': {'field': 'line_color'}, + 'line_width': {'field': 'line_width'}}) + + def test_expand_style_opts_line_color_and_color(self): + style = {'fill_color': 'red', 'color': 'blue'} + opts = ['color', 'line_color', 'fill_color'] + data, mapping = expand_batched_style(style, opts, {}, nvals=3) + self.assertEqual(data['line_color'], ['blue', 'blue', 'blue']) + self.assertEqual(data['fill_color'], ['red', 'red', 'red']) + self.assertEqual(mapping, {'line_color': {'field': 'line_color'}, + 'fill_color': {'field': 'fill_color'}}) + + def test_expand_style_opts_line_alpha_and_alpha(self): + style = {'fill_alpha': 0.5, 'alpha': 0.2} + opts = ['alpha', 'line_alpha', 'fill_alpha'] + data, mapping = expand_batched_style(style, opts, {}, nvals=3) + self.assertEqual(data['line_alpha'], [0.2, 0.2, 0.2]) + self.assertEqual(data['fill_alpha'], [0.5, 0.5, 0.5]) + self.assertEqual(mapping, {'line_alpha': {'field': 'line_alpha'}, + 'fill_alpha': {'field': 'fill_alpha'}}) + + def test_expand_style_opts_color_predefined(self): + style = {'fill_color': 'red'} + opts = ['color', 'line_color', 'fill_color'] + data, mapping = expand_batched_style(style, opts, {'color': 'color'}, nvals=3) + self.assertEqual(data['fill_color'], ['red', 'red', 'red']) + self.assertEqual(mapping, {'fill_color': {'field': 'fill_color'}}) + + def test_filter_batched_data(self): + data = {'line_color': ['red', 'red', 'red']} + mapping = {'line_color': 'line_color'} + filter_batched_data(data, mapping) + self.assertEqual(data, {}) + self.assertEqual(mapping, {'line_color': 'red'}) + + def test_filter_batched_data_as_field(self): + data = {'line_color': ['red', 'red', 'red']} + mapping = {'line_color': {'field': 'line_color'}} + filter_batched_data(data, mapping) + self.assertEqual(data, {}) + self.assertEqual(mapping, {'line_color': 'red'}) + + def test_filter_batched_data_heterogeneous(self): + data = {'line_color': ['red', 'red', 'blue']} + mapping = {'line_color': {'field': 'line_color'}} + filter_batched_data(data, mapping) + self.assertEqual(data, {'line_color': ['red', 'red', 'blue']}) + self.assertEqual(mapping, {'line_color': {'field': 'line_color'}}) diff --git a/tests/testplotinstantiation.py b/tests/testplotinstantiation.py index 1d11fd4ae7..2d0bf8c5d4 100644 --- a/tests/testplotinstantiation.py +++ b/tests/testplotinstantiation.py @@ -12,7 +12,7 @@ import param import numpy as np from holoviews import (Dimension, Overlay, DynamicMap, Store, - NdOverlay, GridSpace, HoloMap, Layout) + NdOverlay, GridSpace, HoloMap, Layout, Cycle) from holoviews.core.util import pd from holoviews.element import (Curve, Scatter, Image, VLine, Points, HeatMap, QuadMesh, Spikes, ErrorBars, @@ -328,6 +328,116 @@ def test_batched_plot(self): extents = plot.get_extents(overlay, {}) self.assertEqual(extents, (0, 0, 98, 98)) + def test_batched_points_size_and_color(self): + opts = {'NdOverlay': dict(plot=dict(legend_limit=0)), + 'Points': dict(style=dict(size=Cycle(values=[1, 2])))} + overlay = NdOverlay({i: Points([(i, j) for j in range(2)]) + for i in range(2)})(opts) + plot = bokeh_renderer.get_plot(overlay).subplots[()] + size = np.array([1, 1, 2, 2]) + color = np.array(['#30a2da', '#30a2da', '#fc4f30', '#fc4f30'], + dtype='