I am trying to dynamically modify my y-axis tick formatting and tooltip formatting based on what is selected in a Holoviews dropdown. I figured I could do this in finalize_hooks. Since I don't know how to test for what has been selected in the dropdown, I used the title value to determine that. This seems to work ok though I am sure there could be a more elegant solution that I am not aware of. Also, I am able to change the tick formatter but the hover value doesn't change based on the above method. See example code below. Tooltip always shows Value1, never Value 2 no matter which country I select. Please advise if there is a way to fix this.
%%opts Bars [show_grid=True width=1400 height=400 xrotation=0] {+framewise}
macro_df = pd.read_csv('http://assets.holoviews.org/macro.csv', '\t')
key_dimensions = [('year', 'Year'), ('country', 'Country')]
value_dimensions = [('unem', 'Unemployment'), ('capmob', 'Capital Mobility'),
('gdp', 'GDP Growth'), ('trade', 'Trade')]
macro = hv.Table(macro_df, key_dimensions, value_dimensions)
hover = HoverTool(tooltips=[('year', '#year'),
('Value', '#unem{0.000%}')])
def apply_formatter(plot, element):
p = plot.handles['plot']
if 'Austria' in p.title.text:
plot.handles['yaxis'].formatter = NumeralTickFormatter(format="0")
p.hover[0].tooltips[1] = ('Value1', '#unem{0.0%}')
else:
plot.handles['yaxis'].formatter = NumeralTickFormatter(format="0.0%")
p.hover[0].tooltips[1] = ('Value2', '#unem{0.00%}')
bars = macro.to(hv.Bars, kdims='year', vdims=['unem']).opts(plot=dict(tools=[hover], finalize_hooks=[apply_formatter]))
bars
This seems to work
from bokeh.models import NumeralTickFormatter
from bokeh.models import HoverTool
macro_df = pd.read_csv('http://assets.holoviews.org/macro.csv', '\t')
key_dimensions = [('year', 'Year'), ('country', 'Country')]
value_dimensions = [('unem', 'Unemployment'), ('capmob', 'Capital Mobility'),
('gdp', 'GDP Growth'), ('trade', 'Trade')]
macro = hv.Table(macro_df, key_dimensions, value_dimensions)
def apply_formatter(plot, element):
p = plot.state
global x
x = p
if 'Austria' in p.title.text:
plot.handles['yaxis'].formatter = NumeralTickFormatter(format="0")
hover = HoverTool(tooltips=[('year', '#year'),
('Value', '#unem{0%}')])
p.tools = [hover]
else:
plot.handles['yaxis'].formatter = NumeralTickFormatter(format="0.0%")
hover = HoverTool(tooltips=[('year', '#year'),
('Value', '#unem{0.00%}')])
p.tools = [hover]
bars = macro.to(hv.Bars, kdims='year', vdims=['unem']).options(
tools=[], finalize_hooks=[apply_formatter])
bars
Related
I’m generating a Bokeh report which uses tabs, sometimes I can get a lot of these and navigating the document becomes really cumbersome. Luckily the plots have some attributes which could be used to group some plots together. So I was trying to implement a way of filtering the number of visible tabs based on these attributes. I was pretty much successful on sketching a solution with bokeh server but my end solution would need to implement a CustomJS callback since I need to distribute the html report. I’m kind of lost since I’m not familiar with how to implement CustomJS callbacks, or even if what I’m trying to achieve is even possible without bokeh server. I tried to implement a CustomJS based on other people posts but so far I've been unsuccesfull.
My main objective would be to substitute the ‘change_plot’ callback with a CustomJS callback, if anyone has a pointer of how this could be possible I’d greatly appreciate some help.
I’m providing a minimal example of my script below. Any help or pointers would be much appreciated.
Bokeh Server version of what I'm trying to achieve:
from bokeh.layouts import column
from bokeh.models import ColumnDataSource, Tabs, Panel, Dropdown, PreText
from bokeh.plotting import figure, curdoc
#Initialize variables
nplots = 6 # Number of plots
ngroup = 4 # Number of plots assigned to first group
# Definition of report structure
groups = [f'Quad' if i < ngroup else f'Linear' for i in range(nplots)] # Arbitrary grouping of plots
tabnames = [f'Title_{i}' for i in range(nplots)] # Individual plot names
# Creates list of unique groups without modifying first appearance order
cnt = 0
unq_grp = []
original_groups = groups[:]
while len(groups):
cnt = cnt + 1
unq_grp.append(groups[0])
groups = list(filter(lambda group: group != groups[0], groups))
if cnt > len(groups):
break
# Data Variables
x = [None]*nplots
y = [None]*nplots
# Plot Variables
fig = [None]*nplots
source = [None]*nplots
# Generates figures with plots from data with custom process
for i in range(nplots):
x[i] = [x[i] for x[i] in range(0, 10)]
if i < ngroup:
y[i] = [(i*n)**2 for n in x[i]]
else:
y[i] = [(i*n) for n in x[i]]
source[i] = ColumnDataSource(data=dict(x=x[i], y=y[i]))
fig[i] = figure()
fig[i].line('x', 'y', source=source[i], line_width=3, line_alpha=0.6)
# Callback to change Plot and Plot Title
def change_plot(attr, old, new):
index = int(new.split(',')[0])
group = int(new.split(',')[1])
title[group].text = f'Plot: {subgroup[group][index][0]}'
col[group].children[2] = fig[index]
subgroup = [None]*len(unq_grp) #List of tuples ('plot_name', ['tabname_index','unique_group_index'])
menu = [None]*len(unq_grp) #List that populates dropdown menu
group_dd = [None]*len(unq_grp) #Placeholder for dropdown GUI elements
tab = [None]*len(unq_grp) #Placeholder for tab GUI elements
title = [None]*len(unq_grp) #Placeholder for title GUI elements
col = [None]*len(unq_grp) #Placeholder for column GUI elements
# Cycle through each unique group
for i, group in enumerate(unq_grp):
# Filter the figures correspondig to current group
subgroup[i] = [(tabnames[j],str(f'{j},{i}')) if original_group == group else None for j, original_group in enumerate(original_groups)]
# Populates the dropdown menu
menu[i] = list(filter(None,subgroup[i]))
# Reference default figure index (first in the menu)
default = int(menu[i][0][1].split(',')[0])
# Creates GUI/Report elements
group_dd[i] = Dropdown(label = "Select Group", button_type = "default", menu=menu[i])
title[i] = PreText(text=f'Plot: {menu[i][0][0]}', width=200)
col[i] = column([group_dd[i],title[i],fig[default]])
# Listens to callback event
group_dd[i].on_change('value', change_plot)
# Creates tabs
tab[i] = Panel(child = col[i], title = group)
out_tabs = Tabs(tabs = tab)
curdoc().title = "Plotting Tool"
curdoc().add_root(out_tabs)
Standalone Report (my code so far...)
from bokeh.layouts import column
from bokeh.models import ColumnDataSource, Tabs, Panel, Dropdown, PreText, CustomJS
from bokeh.plotting import figure, output_file, show
#Initialize variables
nplots = 6 # Number of plots
ngroup = 4 # Number of plots assigned to first group
# Definition of report structure
groups = [f'Quad' if i < ngroup else f'Linear' for i in range(nplots)] # Arbitrary grouping of plots
tabnames = [f'Title_{i}' for i in range(nplots)] # Individual plot names
output_file("tabs.html")
# Creates list of unique groups without modifying first appearance order
cnt = 0
unq_grp = []
original_groups = groups[:]
while len(groups):
cnt = cnt + 1
unq_grp.append(groups[0])
groups = list(filter(lambda group: group != groups[0], groups))
if cnt > len(groups):
break
# Data Variables
x = [None]*nplots
y = [None]*nplots
# Plot Variables
fig = [None]*nplots
source = [None]*nplots
# Generates figures with plots from data with custom process
for i in range(nplots):
x[i] = [x[i] for x[i] in range(0, 10)]
if i < ngroup:
y[i] = [(i*n)**2 for n in x[i]]
else:
y[i] = [(i*n) for n in x[i]]
source[i] = ColumnDataSource(data=dict(x=x[i], y=y[i]))
fig[i] = figure()
fig[i].line('x', 'y', source=source[i], line_width=3, line_alpha=0.6)
figcol = column(fig)
output_file("tabs.html")
subgroup = [None]*len(unq_grp) #List of tuples ('plot_name', ['tabname_index','unique_group_index'])
menu = [None]*len(unq_grp) #List that populates dropdown menu
group_dd = [None]*len(unq_grp) #Placeholder for dropdown GUI elements
tab = [None]*len(unq_grp) #Placeholder for tab GUI elements
title = [None]*len(unq_grp) #Placeholder for title GUI elements
col = [None]*len(unq_grp) #Placeholder for column GUI elements
cjs = [None]*len(unq_grp) #Placeholder for column GUI elements
# Cycle through each unique group
for i, group in enumerate(unq_grp):
# Filter the figures correspondig to current group
subgroup[i] = [(tabnames[j],str(f'{j},{i}')) if original_group == group else None for j, original_group in enumerate(original_groups)]
# Populates the dropdown menu
menu[i] = list(filter(None,subgroup[i]))
# Reference default figure index (first in the menu)
default = int(menu[i][0][1].split(',')[0])
# Creates GUI/Report elements
group_dd[i] = Dropdown(label = "Select Group", button_type = "default", menu=menu[i])
col[i] = column([group_dd[i],fig[default]])
cjs[i] = CustomJS(args=dict(col=col[i], select=group_dd[i], allfigs=figcol), code="""
// Split the index
var dd_val = (select.value)
var valARR = dd_val.split(',')
var index = parseInt(valARR[0])
// replace with appropiate figure?
col.children[1] = allfigs.children[index]
// send new column, maybe?
col.change.emit();
""")
# Listens to callback event
group_dd[i].js_on_change('value',cjs[i])
# Creates tabs
tab[i] = Panel(child = col[i], title = group)
out_tabs = Tabs(tabs = tab)
show(out_tabs)
You can show all the figures in a column layout, and show/hide the figure by setting the visible property.
Here is an example:
import numpy as np
from bokeh.layouts import column
from bokeh.models import ColumnDataSource, Tabs, Panel, Select, PreText, CustomJS
from bokeh.plotting import figure, output_file, show
output_file("tabs.html")
x = np.linspace(0, 10)
def create_figure(x, y, label):
source = ColumnDataSource(data=dict(x=x, y=y))
fig = figure(name=label)
fig.line('x', 'y', source=source, line_width=3, line_alpha=0.6)
return fig
def create_select(figs, title="Select Group"):
names = [fig.name for fig in figs]
drop = Select(title=title, value=names[0], options=names)
for fig in figs[1:]:
fig.visible = False
callback = CustomJS(args=dict(figs=figs), code="""
let selected = cb_obj.value;
for(let fig of figs){
fig.visible = fig.name == selected;
}
""")
drop.js_on_change("value", callback)
return [drop] + figs
quad_figs = [create_figure(x, (i * x)**2, f"Quad {i}") for i in range(3)]
linear_figs = [create_figure(x, (i * x), f"Linear {i}") for i in range(3)]
tabs = Tabs(tabs=[
Panel(child=column(create_select(quad_figs)), title="Quad"),
Panel(child=column(create_select(linear_figs)), title="Linear")
])
show(tabs)
We can easily make an interactive graph with Bokeh where the user can toggle a line on and off but I haven’t found a way to hide the line by default (i.e before the user checks a checkbox). As an example, the code below kind of works but when the user first load the page, all the lines are on by default even know none of them are active (active=[]). The user must then select any checkbox to make them disappear and then select a given checkbox to display a given line which is clumsy. So how would one hide the lines by default before the user selects one of them?
from bokeh.io import output_file, show
from bokeh.layouts import row
from bokeh.plotting import figure
from bokeh.models import CheckboxGroup, CustomJS
output_file("toggle_lines.html")
### Main plot
plot = figure()
# Dummy data for testing
x = list(range(90))
y0 = [ a**1.5 for a in x]
y1 = [ a**1.55 for a in x]
y2 = [ a**1.60 for a in x]
y3 = [ a**1.65 for a in x]
l0 = plot.line(x, y0, color='red')
l1 = plot.line(x, y1, color='red')
l2 = plot.line(x, y2, color='red')
l3 = plot.line(x, y3, color='red')
checkbox = CheckboxGroup(labels=["l0", "l1", "l2", "l3"], active=[])
checkbox.callback = CustomJS(args=dict(l0=l0, l1=l1, l2=l2, l3=l3, checkbox=checkbox),
code="""
l0.visible = 0 in checkbox.active;
l1.visible = 1 in checkbox.active;
l2.visible = 2 in checkbox.active;
l3.visible = 2 in checkbox.active;
""")
layout = row(checkbox, plot)
show(layout)
You can set .visible = False in the python code, for any that you want to start off invisible.
I just wanted to note that the JS code is wrong here, the correct code is
l0.visible = checkbox.active.includes(0);
etc, etc
I'm trying to add interaction to heatmap(using rect) using CustomJS to show another bokeh plot based on the selected value.
This is what I've tried
heat_map_df_stack = pd.DataFrame(heat_map_df.stack(), columns=['rate']).reset_index()
....
issue_heat_map = figure(title="",
x_range=issues, y_range=list(reversed(products)),
x_axis_location="above", plot_width=400, plot_height=400,
tools=TOOLS, toolbar_location='below',
tooltips=[('Product & Issue Id', '#Product #Issue'), ('Issue Count', '#rate')],
name='issue_heat_map')
....
issue_heat_map.rect(x="Issue", y="Product", width=1, height=1,
source=heat_map_df_stack,
fill_color={'field': 'rate', 'transform': mapper},
line_color=None)
....
taptool = issue_heat_map.select(type=TapTool)
taptool.callback = CustomJS(args = dict(source = ""), code =
"""
console.log('test')
console.log(cb_obj)
var inds = cb_obj.selected;
window.alert(inds);
""")
On click of the rect or selection, nothing is happening now.
[Edit] : I updated the above code. Now I'm able to see console log and alert, but have no clue on how to get selected value from the heat map.
Here is a version using the bokeh server. The code is a adaption of the heatmap example from the bokeh gallery.
from math import pi
import pandas as pd
import numpy as np
from bokeh.io import curdoc
from bokeh.models import LinearColorMapper, BasicTicker, PrintfTickFormatter, ColorBar
from bokeh.plotting import figure
from bokeh.models import ColumnDataSource
from bokeh.layouts import gridplot
from bokeh.sampledata.unemployment1948 import data
data['Year'] = data['Year'].astype(str)
data = data.set_index('Year')
data.drop('Annual', axis=1, inplace=True)
data.columns.name = 'Month'
years = list(data.index)
months = list(data.columns)
# reshape to 1D array or rates with a month and year for each row.
df = pd.DataFrame(data.stack(), columns=['rate']).reset_index()
source = ColumnDataSource(df)
# this is the colormap from the original NYTimes plot
colors = ["#75968f", "#a5bab7", "#c9d9d3", "#e2e2e2", "#dfccce", "#ddb7b1", "#cc7878", "#933b41", "#550b1d"]
mapper = LinearColorMapper(palette=colors, low=df.rate.min(), high=df.rate.max())
TOOLS = "hover,save,pan,box_zoom,reset,wheel_zoom, tap"
p = figure(title="US Unemployment ({0} - {1})".format(years[0], years[-1]),
x_range=years, y_range=list(reversed(months)),
x_axis_location="above", plot_width=900, plot_height=400,
tools=TOOLS, toolbar_location='below',
tooltips=[('date', '#Month #Year'), ('rate', '#rate%')])
p.grid.grid_line_color = None
p.axis.axis_line_color = None
p.axis.major_tick_line_color = None
p.axis.major_label_text_font_size = "5pt"
p.axis.major_label_standoff = 0
p.xaxis.major_label_orientation = pi / 3
heatmap = p.rect(x="Year", y="Month", width=1, height=1,
source=source,
fill_color={'field': 'rate', 'transform': mapper},
line_color=None)
color_bar = ColorBar(color_mapper=mapper, major_label_text_font_size="5pt",
ticker=BasicTicker(desired_num_ticks=len(colors)),
formatter=PrintfTickFormatter(format="%d%%"),
label_standoff=6, border_line_color=None, location=(0, 0))
p.add_layout(color_bar, 'right')
# Adding the tap interaction + plot
other_source = ColumnDataSource({'x': range(10), 'y': range(10)})
other_plot = figure(title="Other Plot")
other_line = other_plot.line(x='x', y='y', source=other_source)
def update(attr, old, new):
if not old:
old = [1]
if new:
other_source.data.update(y=np.array(other_source.data['y'])/old[0]*new[0])
source.selected.on_change('indices', update)
curdoc().add_root(gridplot([[p, other_plot]]))
The important part are the last few lines, where I set up the second plot and add the update function to change the slope of the line in the second plot according to the selected rect from the heatmap.
I'm using the bupaR process mining suite and processmapR to plot my log as a process map but when I try to set a custom position (which force the graph to use a neato layout) the edge become almost staight and the edge value hard to read:
Default graph with no custom position:
With custom position:
I tried to use
positions <- data.frame(act = c("node1","node2","node 3","node 4","node 5","Start", "End"),
y = c(5,4,3,2,1,6,0),
x = c(1,2,3,4,5,0,6),
stringsAsFactors = F)
graph = process_map(log, fixed_node_pos = positions, render = F)
map = add_global_graph_attrs(graph,
attr = "splines",
value = "true",
attr_type = "graph")
render_graph(map)
But I could not find any attribute to change the way edge are displayed, like adding more curve to them
How can I fix this problem ?
Thanks
Try the following:
map = add_global_graph_attrs(graph,
attr = "splines",
value = "curved",
attr_type = "graph")
I'm trying to do something that I'd normally consider trivial but seems to be very difficult in bokeh: Adding a vertical colorbar to a plot and then having the title of the colorbar (a.k.a. the variable behind the colormapping) appear to one side of the colorbar but rotated 90 degrees clockwise from horizontal.
From what I can tell of the bokeh ColorBar() interface (looking at both documentation and using the python interpreter's help() function for this element), this is not possible. In desperation I have added my own Label()-based annotation. This works but is klunky and displays odd behavior when deployed in a bokeh serve situation--that the width of the data window on the plot varies inversely with the length of the title colorbar's title string.
Below I've included a modified version of the bokeh server mpg example. Apologies for its complexity, but I felt this was the best way to illustrate the problem using infrastructure/data that ships with bokeh. For those unfamiliar with bokeh serve, this code snippet needs to saved to a file named main.py that resides in a directory--for the sake of argument let's say CrossFilter2--and in the parent directory of CrossFilter2 one needs to invoke the command
bokeh serve --show CrossFilter2
this will then display in a browser window (localhost:5006/CrossFilter2) and if you play with the color selection widget you will see what I mean, namely that short variable names such as 'hp' or 'mpg' result in a wider data display windows than longer variable names such as 'accel' or 'weight'. I suspect that there may be a bug in how label elements are sized--that their x and y dimensions are swapped--and that bokeh has not understood that the label element has been rotated.
My questions are:
Must I really have to go to this kind of trouble to get a simple colorbar label feature that I can get with little-to-no trouble in matplotlib/plotly?
If I must go through the hassle you can see in my sample code, is there some other way I can do this that avoids the data window width problem?
import numpy as np
import pandas as pd
from bokeh.layouts import row, widgetbox
from bokeh.models import Select
from bokeh.models import HoverTool, ColorBar, LinearColorMapper, Label
from bokeh.palettes import Spectral5
from bokeh.plotting import curdoc, figure, ColumnDataSource
from bokeh.sampledata.autompg import autompg_clean as df
df = df.copy()
SIZES = list(range(6, 22, 3))
COLORS = Spectral5
# data cleanup
df.cyl = df.cyl.astype(str)
df.yr = df.yr.astype(str)
columns = sorted(df.columns)
discrete = [x for x in columns if df[x].dtype == object]
continuous = [x for x in columns if x not in discrete]
quantileable = [x for x in continuous if len(df[x].unique()) > 20]
def create_figure():
xs = df[x.value].tolist()
ys = df[y.value].tolist()
x_title = x.value.title()
y_title = y.value.title()
name = df['name'].tolist()
kw = dict()
if x.value in discrete:
kw['x_range'] = sorted(set(xs))
if y.value in discrete:
kw['y_range'] = sorted(set(ys))
kw['title'] = "%s vs %s" % (y_title, x_title)
p = figure(plot_height=600, plot_width=800,
tools='pan,box_zoom,wheel_zoom,lasso_select,reset,save',
toolbar_location='above', **kw)
p.xaxis.axis_label = x_title
p.yaxis.axis_label = y_title
if x.value in discrete:
p.xaxis.major_label_orientation = pd.np.pi / 4
if size.value != 'None':
groups = pd.qcut(df[size.value].values, len(SIZES))
sz = [SIZES[xx] for xx in groups.codes]
else:
sz = [9] * len(xs)
if color.value != 'None':
coloring = df[color.value].tolist()
cv_95 = np.percentile(np.asarray(coloring), 95)
mapper = LinearColorMapper(palette=Spectral5,
low=cv_min, high=cv_95)
mapper.low_color = 'blue'
mapper.high_color = 'red'
add_color_bar = True
ninety_degrees = pd.np.pi / 2.
color_bar = ColorBar(color_mapper=mapper, title='',
#title=color.value.title(),
title_text_font_style='bold',
title_text_font_size='20px',
title_text_align='center',
orientation='vertical',
major_label_text_font_size='16px',
major_label_text_font_style='bold',
label_standoff=8,
major_tick_line_color='black',
major_tick_line_width=3,
major_tick_in=12,
location=(0,0))
else:
c = ['#31AADE'] * len(xs)
add_color_bar = False
if add_color_bar:
source = ColumnDataSource(data=dict(x=xs, y=ys,
c=coloring, size=sz, name=name))
else:
source = ColumnDataSource(data=dict(x=xs, y=ys, color=c,
size=sz, name=name))
if add_color_bar:
p.circle('x', 'y', fill_color={'field': 'c',
'transform': mapper},
line_color=None, size='size', source=source)
else:
p.circle('x', 'y', color='color', size='size', source=source)
p.add_tools(HoverTool(tooltips=[('x', '#x'), ('y', '#y'),
('desc', '#name')]))
if add_color_bar:
color_bar_label = Label(text=color.value.title(),
angle=ninety_degrees,
text_color='black',
text_font_style='bold',
text_font_size='20px',
x=25, y=300,
x_units='screen', y_units='screen')
p.add_layout(color_bar, 'right')
p.add_layout(color_bar_label, 'right')
return p
def update(attr, old, new):
layout.children[1] = create_figure()
x = Select(title='X-Axis', value='mpg', options=columns)
x.on_change('value', update)
y = Select(title='Y-Axis', value='hp', options=columns)
y.on_change('value', update)
size = Select(title='Size', value='None',
options=['None'] + quantileable)
size.on_change('value', update)
color = Select(title='Color', value='None',
options=['None'] + quantileable)
color.on_change('value', update)
controls = widgetbox([x, y, color, size], width=200)
layout = row(controls, create_figure())
curdoc().add_root(layout)
curdoc().title = "Crossfilter"
You can add a vertical label to the Colorbar by plotting it on a separate axis and adding a title to this axis. To illustrate this, here's a modified version of Bokeh's standard Colorbar example (found here):
import numpy as np
from bokeh.plotting import figure, output_file, show
from bokeh.models import LogColorMapper, LogTicker, ColorBar
from bokeh.layouts import row
plot_height = 500
plot_width = 500
color_bar_height = plot_height + 11
color_bar_width = 180
output_file('color_bar.html')
def normal2d(X, Y, sigx=1.0, sigy=1.0, mux=0.0, muy=0.0):
z = (X-mux)**2 / sigx**2 + (Y-muy)**2 / sigy**2
return np.exp(-z/2) / (2 * np.pi * sigx * sigy)
X, Y = np.mgrid[-3:3:100j, -2:2:100j]
Z = normal2d(X, Y, 0.1, 0.2, 1.0, 1.0) + 0.1*normal2d(X, Y, 1.0, 1.0)
image = Z * 1e6
color_mapper = LogColorMapper(palette="Viridis256", low=1, high=1e7)
plot = figure(x_range=(0,1), y_range=(0,1), toolbar_location=None,
width=plot_width, height=plot_height)
plot.image(image=[image], color_mapper=color_mapper,
dh=[1.0], dw=[1.0], x=[0], y=[0])
Now, to make the Colorbar, create a separate dummy plot, add the Colorbar to the dummy plot and place it next to the main plot. Add the Colorbar label as the title of the dummy plot and center it appropriately.
color_bar = ColorBar(color_mapper=color_mapper, ticker=LogTicker(),
label_standoff=12, border_line_color=None, location=(0,0))
color_bar_plot = figure(title="My color bar title", title_location="right",
height=color_bar_height, width=color_bar_width,
toolbar_location=None, min_border=0,
outline_line_color=None)
color_bar_plot.add_layout(color_bar, 'right')
color_bar_plot.title.align="center"
color_bar_plot.title.text_font_size = '12pt'
layout = row(plot, color_bar_plot)
show(layout)
This gives the following output image:
One thing to look out for is that color_bar_width is set wide enough to incorporate both the Colorbar, its axes labels and the Colorbar label. If the width is set too small, you will get an error and the plot won't render.
As of Bokeh 0.12.10 there is no built in label available for colorbars. In addition to your approach or something like it, another possibility would be a custom extension, though that is similarly not trivial.
Offhand, a colobar label certainly seems like a reasonable thing to consider. Regarding the notion that it ought to be trivially available, if you polled all users about what they consider should be trivially available, there will be thousands of different suggestions for what to prioritize. As is very often the case in the OSS world, there are far more possible things to do, than there are people to do them (less than 3 in this case). So, would first suggest a GitHub Issue to request the feature, and second, if you have the ability, volunteering to help implement it. Your contribution would be valuable and appreciated by many.