Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bokeh colorbars #861

Merged
merged 21 commits into from Sep 14, 2016
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
72ca81b
Implemented ColorbarPlot and LegendPlot baseclasses in bokeh
philippjfr Sep 13, 2016
14048bd
Added client side colormapping for PointPlot
philippjfr Sep 13, 2016
3e383f8
Added client side colormapping for SideHistogramPlot
philippjfr Sep 13, 2016
883e185
Added client side colormapping for PolygonPlot
philippjfr Sep 13, 2016
ac35b2f
Added colorbars for RasterPlot
philippjfr Sep 13, 2016
da511ff
Added client-side colormapping for QuadMeshPlot
philippjfr Sep 13, 2016
59b9473
Added client side colormapping for SpikesPlot
philippjfr Sep 13, 2016
84a0dc4
Moved toolbar on top to avoid clashes with colorbar
philippjfr Sep 13, 2016
dee556f
Added client side colormapping for HeatmapPlot
philippjfr Sep 14, 2016
f2884e1
Handled updating of colormapping ranges
philippjfr Sep 14, 2016
8f00b04
Added colorbar border by default
philippjfr Sep 14, 2016
af0b2dd
Made toolbar position customizable
philippjfr Sep 14, 2016
122a3ba
Cleaned up ColorbarPlot and added docstring
philippjfr Sep 14, 2016
b219c23
Defined default colormap
philippjfr Sep 14, 2016
8a83577
Added colormapping unit tests
philippjfr Sep 14, 2016
65c0d21
Added colormapper to update handles of ColorbarPlot
philippjfr Sep 14, 2016
c211f2a
Fixed setting of colorbar border
philippjfr Sep 14, 2016
f520bff
Allowed updating the colormapper palette
philippjfr Sep 14, 2016
c35d6b2
Made colorbar defaults a class attribute
philippjfr Sep 14, 2016
80899e6
Renamed mapper to cmapper
philippjfr Sep 14, 2016
9e2f1b6
Added comment about colormapper instances
philippjfr Sep 14, 2016
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
64 changes: 25 additions & 39 deletions holoviews/plotting/bokeh/chart.py
Expand Up @@ -12,12 +12,12 @@
from ...core.util import max_range, basestring, dimension_sanitizer
from ...core.options import abbreviated_exception
from ..util import compute_sizes, get_sideplot_ranges, match_spec, map_colors
from .element import ElementPlot, line_properties, fill_properties
from .element import ElementPlot, ColorbarPlot, line_properties, fill_properties
from .path import PathPlot, PolygonPlot
from .util import get_cmap, mpl_to_bokeh, update_plot, rgb2hex, bokeh_version


class PointPlot(ElementPlot):
class PointPlot(ColorbarPlot):

color_index = param.ClassSelector(default=3, class_=(basestring, int),
allow_None=True, doc="""
Expand Down Expand Up @@ -55,21 +55,12 @@ def get_data(self, element, ranges=None, empty=False):
mapping = dict(x=dims[xidx], y=dims[yidx])
data = {}

cmap = style.get('palette', style.get('cmap', None))
cdim = element.get_dimension(self.color_index)
if cdim and cmap:
map_key = 'color_' + cdim.name
mapping['color'] = map_key
if empty:
data[map_key] = []
else:
cmap = get_cmap(cmap)
colors = element.dimension_values(self.color_index)
if colors.dtype.kind in 'if':
crange = ranges.get(cdim.name, element.range(cdim.name))
else:
crange = np.unique(colors)
data[map_key] = map_colors(colors, crange, cmap)
if cdim:
mapper = self._get_colormapper(cdim, element, ranges, style)
data[cdim.name] = [] if empty else element.dimension_values(cdim)
mapping['color'] = {'field': cdim.name,
'transform': mapper}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks much cleaner!

sdim = element.get_dimension(self.size_index)
if sdim:
Expand Down Expand Up @@ -98,7 +89,7 @@ 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].append(eld)
if 'color' not in eldata:
if 'color' not in elmapping:
zorder = self.get_zorder(element, key, el)
val = style[zorder].get('color')
elmapping['color'] = 'color'
Expand Down Expand Up @@ -128,6 +119,8 @@ def _init_glyph(self, plot, mapping, properties):
else:
plot_method = self._plot_methods.get('batched' if self.batched else 'single')
renderer = getattr(plot, plot_method)(**dict(properties, **mapping))
if self.colorbar and 'color_mapper' in self.handles:
self._draw_colorbar(plot, self.handles['color_mapper'])
return renderer, renderer.glyph
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When could you request a colorbar but not have a color_mapper available?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They might have enabled colorbar by default but not set a color_index.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, makes sense, thanks.



Expand Down Expand Up @@ -239,7 +232,7 @@ def get_data(self, element, ranges=None, empty=None):
return (data, mapping)


class SideHistogramPlot(HistogramPlot):
class SideHistogramPlot(HistogramPlot, ColorbarPlot):

style_opts = HistogramPlot.style_opts + ['cmap']

Expand All @@ -262,19 +255,20 @@ def get_data(self, element, ranges=None, empty=None):
data = dict(top=element.values, left=element.edges[:-1],
right=element.edges[1:])

dim = element.get_dimension(0).name
dim = element.get_dimension(0)
main = self.adjoined.main
range_item, main_range, dim = get_sideplot_ranges(self, element, main, ranges)
vals = element.dimension_values(dim)
range_item, main_range, _ = get_sideplot_ranges(self, element, main, ranges)
if isinstance(range_item, (Raster, Points, Polygons, Spikes)):
style = self.lookup_options(range_item, 'style')[self.cyclic_index]
else:
style = {}

if 'cmap' in style or 'palette' in style:
cmap = get_cmap(style.get('cmap', style.get('palette', None)))
data['color'] = [] if empty else map_colors(vals, main_range, cmap)
mapping['fill_color'] = 'color'
main_range = {dim.name: main_range}
mapper = self._get_colormapper(dim, element, main_range, style)
data[dim.name] = [] if empty else element.dimension_values(dim)
mapping['fill_color'] = {'field': dim.name,
'transform': mapper}
self._get_hover_data(data, element, empty)
return (data, mapping)

Expand Down Expand Up @@ -314,7 +308,7 @@ def get_data(self, element, ranges=None, empty=False):
return (data, dict(self._mapping))


class SpikesPlot(PathPlot):
class SpikesPlot(PathPlot, ColorbarPlot):

color_index = param.ClassSelector(default=1, class_=(basestring, int), doc="""
Index of the dimension from which the color will the drawn""")
Expand Down Expand Up @@ -352,22 +346,14 @@ def get_data(self, element, ranges=None, empty=False):
xs, ys = zip(*(((x[0], x[0]), (pos+height, pos))
for x in element.array(dims[:1])))

if not empty and self.invert_axes: keys = keys[::-1]
if not empty and self.invert_axes: xs, ys = ys, xs
data = dict(zip(('xs', 'ys'), (xs, ys)))

cmap = style.get('palette', style.get('cmap', None))
cdim = element.get_dimension(self.color_index)
if cdim and cmap:
map_key = 'color_' + cdim.name
mapping['color'] = map_key
if empty:
colors = []
else:
cmap = get_cmap(cmap)
cvals = element.dimension_values(cdim)
crange = ranges.get(cdim.name, None)
colors = map_colors(cvals, crange, cmap)
data[map_key] = colors
if cdim:
mapper = self._get_colormapper(cdim, element, ranges, style)
data[cdim.name] = [] if empty else element.dimension_values(cdim)
mapping['color'] = {'field': cdim.name,
'transform': mapper}

if 'hover' in self.tools+self.default_tools and not empty:
for d in dims:
Expand Down
179 changes: 156 additions & 23 deletions holoviews/plotting/bokeh/element.py
Expand Up @@ -7,6 +7,14 @@
from bokeh.models.tickers import Ticker, BasicTicker, FixedTicker
from bokeh.models.widgets import Panel, Tabs

from bokeh.models.mappers import LinearColorMapper
try:
from bokeh.models import ColorBar
from bokeh.models.mappers import LogColorMapper
except ImportError:
LogColorMapper, ColorBar = None, None
from bokeh.models import LogTicker, BasicTicker

try:
from bokeh import mpl
except ImportError:
Expand All @@ -22,7 +30,8 @@
from ..util import dynamic_update
from .callbacks import Callbacks
from .plot import BokehPlot
from .util import mpl_to_bokeh, convert_datetime, update_plot, bokeh_version
from .util import (mpl_to_bokeh, convert_datetime, update_plot,
bokeh_version, mplcmap_to_palette)

if bokeh_version >= '0.12':
from bokeh.models import FuncTickFormatter
Expand Down Expand Up @@ -104,6 +113,13 @@ class ElementPlot(BokehPlot, GenericElementPlot):
tools = param.List(default=[], doc="""
A list of plugin tools to use on the plot.""")

toolbar = param.ObjectSelector(default='right',
objects=["above", "below",
"left", "right", None],
doc="""
The toolbar location, must be one of 'above', 'below',
'left', 'right', None.""")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume None can be a sensible choice even if the user has added tools (e.g to temporarily hide the toolbar). Otherwise, adding tools with toolbar=None could issue a warning...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's just a convenient way to disable the toolbar altogether. I could either disable it, or warn tools or warn for tools and default_tools.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think being able to disable the toolbar without issuing warnings could be convenient so I am happy to leave it as it is.


xaxis = param.ObjectSelector(default='bottom',
objects=['top', 'bottom', 'bare', 'top-bare',
'bottom-bare', None], doc="""
Expand Down Expand Up @@ -268,7 +284,6 @@ def _init_plot(self, key, element, plots, ranges=None):
axis_types, labels, plot_ranges = self._axes_props(plots, subplots, element, ranges)
xlabel, ylabel, _ = labels
x_axis_type, y_axis_type = axis_types
tools = self._init_tools(element)
properties = dict(plot_ranges)
properties['x_axis_label'] = xlabel if 'x' in self.show_labels else ' '
properties['y_axis_label'] = ylabel if 'y' in self.show_labels else ' '
Expand All @@ -278,10 +293,15 @@ def _init_plot(self, key, element, plots, ranges=None):
else:
title = ''

if self.toolbar:
tools = self._init_tools(element)
properties['tools'] = tools
properties['toolbar_location'] = self.toolbar

properties['webgl'] = Store.renderers[self.renderer.backend].webgl
return bokeh.plotting.Figure(x_axis_type=x_axis_type,
y_axis_type=y_axis_type, title=title,
tools=tools, **properties)
**properties)


def _plot_properties(self, key, plot, element):
Expand Down Expand Up @@ -618,6 +638,138 @@ def framewise(self):
for frame in current_frames)



class ColorbarPlot(ElementPlot):
"""
ColorbarPlot provides methods to create colormappers and colorbar
models which can be added to a glyph. Additionally it provides
parameters to control the position and other styling options of
the colorbar. The default colorbar_position options are defined
by the colorbar_specs, but may be overridden by the colorbar_opts.
"""

colorbar_specs = {'right': {'pos': 'right',
'opts': {'location': (0, 0)}},
'left': {'pos': 'left',
'opts':{'location':(0, 0)}},
'bottom': {'pos': 'below',
'opts': {'location': (0, 0),
'orientation':'horizontal'}},
'top': {'pos': 'above',
'opts': {'location':(0, 0),
'orientation':'horizontal'}},
'top_right': {'pos': 'center',
'opts': {'location': 'top_right'}},
'top_left': {'pos': 'center',
'opts': {'location': 'top_left'}},
'bottom_left': {'pos': 'center',
'opts': {'location': 'bottom_left',
'orientation': 'horizontal'}},
'bottom_right': {'pos': 'center',
'opts': {'location': 'bottom_right',
'orientation': 'horizontal'}}}

colorbar = param.Boolean(default=False, doc="""
Whether to display a colorbar.""")

colorbar_position = param.ObjectSelector(objects=list(colorbar_specs),
default="right", doc="""
Allows selecting between a number of predefined colorbar position
options. The predefined options may be customized in the
colorbar_specs class attribute.""")

colorbar_opts = param.Dict(default={}, doc="""
Allows setting specific styling options for the colorbar overriding
the options defined in the colorbar_specs class attribute. Includes
location, orientation, height, width, scale_alpha, title, title_props,
margin, padding, background_fill_color and more.""")

logz = param.Boolean(default=False, doc="""
Whether to apply log scaling to the z-axis.""")

_update_handles = ['color_mapper', 'source', 'glyph']

def _draw_colorbar(self, plot, color_mapper):
if LogColorMapper and isinstance(color_mapper, LogColorMapper):
ticker = LogTicker()
else:
ticker = BasicTicker()
cbar_opts = dict(self.colorbar_specs[self.colorbar_position])

# Check if there is a colorbar in the same position
pos = cbar_opts['pos']
if any(isinstance(model, ColorBar) for model in getattr(plot, pos, [])):
return

opts = dict(cbar_opts['opts'], bar_line_color='black',
label_standoff=8, major_tick_line_color='black')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't necessarily make these parameters but maybe they could be specified as a dictionary in the class attributes. Alternatively, you could just make them into the default for colorbar_opts which might make more sense...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When you add them to the colorbar_opts they all get overridden when the user supplies new options and bar_line_color='black' just looks silly without major_tick_line_color='black', so I wanted them to behave like defaults. Happy to make them into a class attribute though.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A class attribute would be good in that case.

color_bar = ColorBar(color_mapper=color_mapper, ticker=ticker,
**dict(opts, **self.colorbar_opts))

plot.add_layout(color_bar, pos)
self.handles['colorbar'] = color_bar


def _get_colormapper(self, dim, element, ranges, style):
low, high = ranges.get(dim.name)
palette = mplcmap_to_palette(style.pop('cmap', 'viridis'))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I am happy about hard coding 'viridis' as a default, especially as it is a matplotlib colormap (unless bokeh now has viridis?).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bokeh does now have viridis.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Matplotlib has hardcoded defaults, which is viridis now and bokeh does have viridis now as well.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great!

Copy link
Member

@jbednar jbednar Sep 14, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally, I like that viridis is perceptually uniform, but I don't actually like it overall, because it has no intuitive ordering. Hot is clearly ordered in a way that people can appreciate at first glance, as are cool colormaps (black->blue->white), but viridis just has to be memorized. In that sense it's as bad as jet (but only that sense).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plus that would be a (very slight) change to the current defaults. Probably few people besides me could tell the difference, though.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And we would have to come up with a name for it! :-)

Definitely good suggestions though and something we should consider doing for 1.7.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mpl's hot definitely has regions where it plateaus and conveys little information about changes in value:

image

It looks good to me up until it turns yellow, then it's got a huge yellow stretch with little change as intensity varies. It wouldn't be hard to do a better job, and I'd be happy to do that.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like mpl's hot came from matlab originally? http://ab-initio.mit.edu/wiki/index.php/Color_tables_in_h5topng

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And we would have to come up with a name for it!

Mpl has hot, afmhot, and gist_heat, so ours could be hhot. :-)

colormapper = LogColorMapper if self.logz else LinearColorMapper
cmapper = colormapper(palette, low=low, high=high)
if 'color_mapper' not in self.handles:
self.handles['color_mapper'] = cmapper
return cmapper
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can see the logic of returning cmapper but this function could also just be called for the side-effect of adding 'color_mapper' to the handles. I had to check the code here to make sure it is indeed the same thing...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, there's some annoyances I have with this, ideally the handles would be set elsewhere but since it is now called from get_data, which is the main method the user has to implement and is duplicated everywhere I didn't want to move it out. The other thing is only the first colormapper that's created is actually used while the rest are simply used to update the existing colormapper. I'll leave it up to you if you think I should find a cleaner solution otherwise I'll make sure to leave a comment.



def _init_glyph(self, plot, mapping, properties):
"""
Returns a Bokeh glyph object and optionally creates a colorbar.
"""
ret = super(ColorbarPlot, self)._init_glyph(plot, mapping, properties)
if self.colorbar and 'color_mapper' in self.handles:
self._draw_colorbar(plot, self.handles['color_mapper'])
return ret


def _update_glyph(self, glyph, properties, mapping):
allowed_properties = glyph.properties()
cmappers = [v.get('transform') for v in mapping.values()
if isinstance(v, dict)]
cmappers.append(properties.pop('color_mapper', None))
for cm in cmappers:
if cm:
self.handles['color_mapper'].low = cm.low
self.handles['color_mapper'].high = cm.high
merged = dict(properties, **mapping)
glyph.set(**{k: v for k, v in merged.items()
if k in allowed_properties})


class LegendPlot(ElementPlot):

legend_position = param.ObjectSelector(objects=["top_right",
"top_left",
"bottom_left",
"bottom_right",
'right', 'left',
'top', 'bottom'],
default="top_right",
doc="""
Allows selecting between a number of predefined legend position
options. The predefined options may be customized in the
legend_specs class attribute.""")


legend_cols = param.Integer(default=False, doc="""
Whether to lay out the legend as columns.""")


legend_specs = {'right': dict(pos='right', loc=(5, -40)),
'left': dict(pos='left', loc=(0, -40)),
'top': dict(pos='above', loc=(120, 5)),
'bottom': dict(pos='below', loc=(60, 0))}



class BokehMPLWrapper(ElementPlot):
"""
Wraps an existing HoloViews matplotlib plot and converts
Expand Down Expand Up @@ -710,22 +862,8 @@ def update_frame(self, key, ranges=None, element=None):
self.handles['plot'] = self._render_plot(element)


class OverlayPlot(GenericOverlayPlot, ElementPlot):
class OverlayPlot(GenericOverlayPlot, LegendPlot):

legend_position = param.ObjectSelector(objects=["top_right",
"top_left",
"bottom_left",
"bottom_right",
'right', 'left',
'top', 'bottom'],
default="top_right",
doc="""
Allows selecting between a number of predefined legend position
options. The predefined options may be customized in the
legend_specs class attribute.""")

legend_cols = param.Integer(default=False, doc="""
Whether to lay out the legend as columns.""")

tabs = param.Boolean(default=False, doc="""
Whether to display overlaid plots in separate panes""")
Expand All @@ -734,11 +872,6 @@ class OverlayPlot(GenericOverlayPlot, ElementPlot):

_update_handles = ['source']

legend_specs = {'right': dict(pos='right', loc=(5, -40)),
'left': dict(pos='left', loc=(0, -40)),
'top': dict(pos='above', loc=(120, 5)),
'bottom': dict(pos='below', loc=(60, 0))}

def _process_legend(self):
plot = self.handles['plot']
if not self.show_legend or len(plot.legend) == 0:
Expand Down