Updating legend when using bokeh js

Hi,

I have been using bokeh with python for some time now. For this new project, I have a huge amount of lines that I need to be able to plot in the same graph, with options to see only some of them at the time.

I found that plotting all glyphs in the same plot, and just make the ones that are not “chosen” invisible, was extremely time consuming wrt loading the figure.

Instead, I use BokehJS in the CustomJS callback to be able to add and remove different glyphs from the plot. It is working fine, but I am struggling with how to update the legends.

Initially, I am adding the legends in the python script by using LegendItems:
legend = Legend(items=legend_items_initial)
plot.add_layout(legend)

Later on, in the BokehJS, I am able to access the legend in the callback, but when I try to update it, it seems like a second plot is added on top of the initial figure. I get the following error-message:

“bokeh-1.3.1.min.js:31 Uncaught TypeError: Cannot read property ‘draw_legend’ of undefined”

My way of trying to update the legend is simply:
legends.items.push(new_legend_item)

There seems to be a link between the figure and the legend, but i cannot access it through e.g. plot.legend. Is there any other way?

An excerpt of my callback is shown below:

checkbox_callback = CustomJS(args = dict(source_list = checkbox_sources, labels = checkbox_labels, glyphs = checkbox_glyphs,
                                         plot = plot, y_ranges = checkbox_yranges, already_active = already_active, 
                                         renderer_list = renderer_list, yaxes = yaxes, legends = legend, legend_items = legend_items), code = """
    var require = Bokeh.require;
    var plt = Bokeh.Plotting.require;
    
    const selected_values = cb_obj.active
    
    //index of glyph in glyphs
    //Remove a glyph if it is unclicked in the checkbox
    var remove_glyphs = already_active.data['active'].filter(function(obj) { return selected_values.indexOf(obj) == -1; });
    var remove_glyphs_index = remove_glyphs[0]
    var renderer_index = renderer_list.data['renderer_index'].indexOf(remove_glyphs_index)
   
    if (remove_glyphs.length != 0) {
        
        // visible = false is done because there is a lag between renderer update and plot update -> plot is not updated immediately
        plot.renderers[renderer_index].visible = false;
        plot.renderers.splice(renderer_index, 1);

        renderer_list.data['renderer_index'].splice(renderer_index, 1);
        
        var y_range_name = y_ranges[remove_glyphs_index]
        
        var location = yaxes[y_range_name]['location']
        var axis_index = yaxes[y_range_name]['index']
        
        old_no_active = yaxes[y_range_name]['number_of_active']
        
        yaxes[y_range_name]['number_of_active'] = old_no_active - 1
        
        if (yaxes[y_range_name]['number_of_active'] == 0){
            if (location == 'left'){
            plot.left[axis_index].visible = false;
            } else {
            plot.right[axis_index].visible = false;
            }
        }

        plot.change.emit();
        }
    
    //Add a glyph if it is selected
    var new_glyphs = selected_values.filter(function(obj) { return already_active.data['active'].indexOf(obj) == -1; });
    var new_glyphs_index = new_glyphs[0];

    if (new_glyphs.length != 0) {
        
        var new_glyph = plot.add_glyph(glyphs[new_glyphs_index], source_list[new_glyphs_index]);
        renderer_list.data['renderer_index'].push(new_glyphs_index);

        var y_range_name = y_ranges[new_glyphs_index]
        plot.extra_y_ranges[y_range_name].renderers.push(new_glyph)
        
        plot.renderers[plot.renderers.length - 1].y_range_name = y_range_name
        
        var location = yaxes[y_range_name]['location']
        var axis_index = yaxes[y_range_name]['index']
        
        if (location == 'left'){
            plot.left[axis_index].visible = true;
            }
        
        if (location == 'right'){
            plot.right[axis_index].visible = true;
            }
        
        old_no_active = yaxes[y_range_name]['number_of_active']
        
        yaxes[y_range_name]['number_of_active'] = old_no_active + 1
        
        
        legends.items.push(legend_items[new_glyphs_index])
        
        plot.change.emit();
        
        
        }
    

    already_active.data['active'] = selected_values
    already_active.change.emit()
    
    renderer_list.change.emit()
    
""")

There’s too much here to really speculate without running a complete minimal example. If I had one, I would set BOKEH_MINIFIED=no when generating the output, then view it in a browser with dev tools open, in order to break when the exception occurs, and look at the values of the variables in callback .

My bad. Here is an example that you should be able to run. NB, you need to refresh the page after showing to include the bokehjs api. Basically, I am adding a glyph to the plot, and trying to also update the legend.

import os

from bokeh.plotting import figure, output_file, show
from bokeh.models import ColumnDataSource, CustomJS, HoverTool
from bokeh.models.glyphs import Line
from bokeh.models import GlyphRenderer
from bokeh.models.widgets import CheckboxGroup
from bokeh.models import Legend, LegendItem
from bokeh.layouts import layout


def api_to_html(file):
    
    with open (file, 'r') as html:
        contents = html.readlines()
    
    new_line = '		<script type="text/javascript" src="https://cdn.pydata.org/bokeh/release/bokeh-api-1.3.1.min.js"></script>\n'
    contents.insert(20, new_line)
    contents = "".join(contents)
    
    with open (file, 'w') as html:
        html.write(contents)
        
    return

output_file("line.html")

p = figure(plot_width=400, plot_height=400)

source1 = ColumnDataSource(data=dict(
    x=[1, 2, 3, 4, 5],
    y=[1, 2, 4, 3, 4],
))

source2 = ColumnDataSource(data=dict(
    x=[1, 3, 6, 7, 9],
    y=[1, 2, 4, 3, 4],
))

source3 = ColumnDataSource(data=dict(
    x=[2, 4, 5, 7, 9],
    y=[1, 2, 4, 3, 5],
))

# add two line renderers to the plot, and make one additional to be used in the callback

glyph1 = Line(x = 'x', y = 'y', line_color = 'red')
glyph_renderer1 = GlyphRenderer(data_source=source1, glyph=glyph1)
p.renderers.append(glyph_renderer1)

glyph2 = Line(x = 'x', y = 'y', line_color = 'blue')
glyph_renderer2 = GlyphRenderer(data_source=source2, glyph=glyph2)
p.renderers.append(glyph_renderer2)

glyph3 = Line(x = 'x', y = 'y', line_color = 'green')
glyph_renderer3 = GlyphRenderer(data_source=source3, glyph=glyph3)

source_list = [source1, source2, source3]

glyph_list = [glyph1, glyph2, glyph3]

#Map renderers in plot to glyph_list
renderer_list = ColumnDataSource(data =  {'renderer_index': [0, 1]})

checkbox_labels = ['glyph1', 'glyph2', 'glyph3']
checkbox_active = [0, 1]

checkbox_group = CheckboxGroup(
        labels=checkbox_labels, active=checkbox_active)


#Keep track of previously chosen glyphs
already_active_glyphs = ColumnDataSource({'active': checkbox_active}) 

tooltips = [('x', '@x'), ('y', '@y')] 

p.add_tools(HoverTool(renderers=[glyph_renderer1], 
                             tooltips=tooltips, 
                             mode='mouse'))
p.add_tools(HoverTool(renderers=[glyph_renderer2], 
                             tooltips=tooltips, 
                             mode='mouse'))


legend_items = [LegendItem(label = 'glyph1', renderers = [glyph_renderer1], index = 0),
                    LegendItem(label = 'glyph2', renderers = [glyph_renderer2], index = 0),
                    LegendItem(label = 'glyph3', renderers = [glyph_renderer3], index = 0)]

legend_items_initial = [LegendItem(label = 'glyph1', renderers = [glyph_renderer1], index = 0),
                    LegendItem(label = 'glyph2', renderers = [glyph_renderer2], index = 0)]

legend = Legend(items = legend_items_initial)
p.add_layout(legend)    


checkbox_callback = CustomJS(args = dict(source_list = source_list, labels = checkbox_labels, glyphs = glyph_list,
                                         plot = p, already_active = already_active_glyphs, 
                                         renderer_list = renderer_list, legend = legend, legend_items = legend_items, 
                                         tooltips = tooltips), code = """
    
    
    var require = Bokeh.require;
    var plt = Bokeh.Plotting.require;
    
    const selected_values = cb_obj.active
    
    //index of glyph in glyphs
    var remove_glyphs = already_active.data['active'].filter(function(obj) { return selected_values.indexOf(obj) == -1; });
    var remove_glyphs_index = remove_glyphs[0]
    var renderer_index = renderer_list.data['renderer_index'].indexOf(remove_glyphs_index)
    
    if (remove_glyphs.length != 0) {
        
        // visible = false is done because there is a lag between renderer update and plot update -> plot is not updated immediately
        plot.renderers[renderer_index].visible = false;
        plot.renderers.splice(renderer_index, 1);

        renderer_list.data['renderer_index'].splice(renderer_index, 1);

        plot.toolbar.tools.splice(renderer_index, 1)
        
        legend.items.splice(renderer_index, 1)
        plot.change.emit();
        }
    
    var new_glyphs = selected_values.filter(function(obj) { return already_active.data['active'].indexOf(obj) == -1; });
    var new_glyphs_index = new_glyphs[0];

    if (new_glyphs.length != 0) {
        
        var new_glyph = plot.add_glyph(glyphs[new_glyphs_index], source_list[new_glyphs_index]);
        renderer_list.data['renderer_index'].push(new_glyphs_index);
        
        var hover = new Bokeh.HoverTool({
                    renderers: [new_glyph], 
                    tooltips: tooltips, 
                    mode: 'mouse'});

        plot.add_tools(hover);
        
        legend.items.push(legend_items[new_glyphs_index])
        plot.change.emit();
        }
    
    
    already_active.data['active'] = selected_values
    already_active.change.emit()
    
    renderer_list.change.emit()
    
""")
    
checkbox_group.js_on_click(checkbox_callback)
layout = layout([
            [p],
            [checkbox_group]])
show(layout)
api_to_html(os.getcwd() + '\\line.html')

I’ve only had time to make a quick look. First off, tangentially, these lines should be deleted:

var require = Bokeh.require;
var plt = Bokeh.Plotting.require;

Also I think adding new HoverTool ad infinitum will also run into issues. I think you’d want to add just one hover tool for all the renderers, or perhaps a single hover tool per-renderer, up front (see also below). I just commented out those lines in my tests.

From a quick glance, it looks like the JS-side object that you are adding back to the legend has been garbage collected/deleted at the time you are adding it back. That’s maybe not surprising, I’m not sure, I’ll have to think about it some more. In general though, I would say that we try to discourage this sort of manipulation of the object graph structure.[1] The area where Bokeh shines is setting up a fairly fixed up-front object graph, then manipulating the properties of those existing objects. To that end, I’d go as far as saying it would be preferable for us to invest time in making a visible property on LegendItem such that setting to false hides the item from the Legend. Then you could simply toggle a property to hide legend items (an operation Bokeh is good at) and not have to move whole objects around (which Bokeh is sometimes less good at).


  1. Being spread across two different runtimes with lots of signal/event handlers wired up on both sides, each with their own (differnet) memory management systems, makes changing the structure of the object graph often very difficult. There are certainly some cases that I consider well-supported, e.g. adding widgets, or whole plots to layouts. But once you start to get inside the structure of a Plot, things are alot more dicey. ↩︎