Legend Item on click handler

Hi! I have a plot with multiple line glyphs and would like to mute all other glyphs when a legend item representing one is clicked. Ideally it would un-mute all glyphs when it is clicked again. The behavior is similar to using p.legend.click_policy = "mute" except I want to mute all others instead of the clicked item.

Is it possible to achieve this?


The code bellow shows what I am after (but i understand it is failing because the events.Tap event is only available on a ‘plot’ model)

from bokeh.models.callbacks import CustomJS

p = figure()
p.line(x=[0,1,2,3,4], y=[1,2,4,8,16], legend_label='line1')
p.legend.location = 'bottom_right'

cb = CustomJS(code="console.log('hello')")

# Plot callback
p.js_on_event('tap', cb)  # Works ... everywhere

# Try add tap event handler to legend item. Fails. Event is not available on this model.
p.legend.items[0].js_on_event('tap', cb)

show(p)

Thanks for you time.

The easiest way to achieve this is to create a custom Legend model with a third click policy, something like mute_inverse. The code in legend.ts is pretty straightforward (you need the on_hit method of the view).
More information on how to create custom models: https://docs.bokeh.org/en/latest/docs/user_guide/extensions.html

I achieved the very same thing by tying the renderer to a source (ColumnDataSource) and view (CDSView). I then assigned a CustomJS callback to the legend item. This callback amends the view’s filters property. I then triggered an update of the plot by emitting a change from the source (referenced by the renderer). The callback is in JS so it all runs very quickly in the browser.

A self-contained example follows.

from bokeh.io import output_notebook, show
from bokeh.models import CDSView, ColumnDataSource, CustomJS, IndexFilter, Legend, LegendItem
from bokeh.plotting import figure

# Load BokehJS library. Remove if not running in Jupyter.
output_notebook()

# Create a data source having data for both lines.
source = ColumnDataSource(
    data={
        'x': [0,1,2,3,4],
        'y': [1,2,4,8,16],
    },
)

# Create views for each line using some filters. Name the filters. This will come in handy later.
filter1 = IndexFilter(indices=source.data['x'][:2], name='filter1')  # first two points as an example
view1 = CDSView(filters=[filter1], source=source)
filter2 = IndexFilter(indices=source.data['x'][2:], name='filter2')  # remaining points
view2 = CDSView(filters=[filter2], source=source)

# Draw the line based on the data source and view.
p = figure()
r1 = p.line(x='x', y='y', source=source, view=view1, name='line1', muted_alpha=0.2)
r2 = p.line(x='x', y='y', source=source, view=view2, name='line2', muted_alpha=0.2)

# Manually construct a legend.
legend = Legend(
    items=[
        LegendItem(label='line1', renderers=[r1]),
        LegendItem(label='line2', renderers=[r2]),
    ],
    location='bottom_right',
    click_policy='mute',  # or hide
)
p.add_layout(legend)

# For each renderer, create a callback to filter it in/out of view.
for r, view, idx_filter in zip([r1, r2], [view1, view2], [filter1, filter2]):
    cb = CustomJS(
        args=dict(source=source, view=view, idx_filter=idx_filter),
        code="""
        console.log(cb_obj);
        var visible = cb_obj.visible;
        // The renderer is currently visibile, but it's legend item was clicked, so the user has requested to make it invisible.
        if (visible) {
            const filters = view.filters;
            // Remove filters matching the given filter's name property.
            view.filters = filters.reduce(
                (a, e, i) => (e.name != idx_filter.name) ? a.concat(e) : a, []
            );
        }
        // The renderer is currently invisibile, but it's legend item was clicked, so the user has requested to make it visible.
        else {
            // Add the filter to the view.
            view.filters.push(idx_filter);
        }
        source.change.emit();
        """,
    )
    # Register the callback.
    r.js_on_event('tap', cb)  # or js_on_change('visible', cb)

show(p)

I’ve actually used this technique with much more complicated legend(s) on a scatter plot – it had a legend for the colors and a separate legend for the marker. Just putting this out there in case anyone stumbles on this with the same issue.

Not sure I follow. In your code, you have attached the callbacks to the line renderers, not to the legend items. Your callback code calls console.log, but nothing actually makes it to the console when you click on a legend item because the callback is not called.

Sorry, you’re correct. My initial post had some mistakes:

  1. I am assigning the callback to the renderer, not the legend item, and I’m relying on the legend’s click_policy to trigger the callback on the renderer.

  2. This only works for js_on_change with the visible property and click_policy set to 'hide'. I need to investigate for the 'mute' policy.

  3. The view needs to be shared amongst the renderers. Previously, I had used separate views.

Here’s the re-worked answer with a click_policy of 'hide':

from bokeh.io import output_notebook, show
from bokeh.models import CDSView, ColumnDataSource, CustomJS, IndexFilter, Legend, LegendItem
from bokeh.plotting import figure

# Load BokehJS library. Remove if not running in Jupyter.
output_notebook()

# Create a data source having data for both lines.
source = ColumnDataSource(
    data={
        'x': [0,1,2,3,4],
        'y': [1,2,4,8,16],
    },
)

# Create an initial view of all data.
view = CDSView(filters=[], source=source)

# Create views for each line using some filters. Name the filters. This will come in handy later.
filters = [
    IndexFilter(indices=source.data['x'][:2], name='filter1'),  # first two points as an example
    IndexFilter(indices=source.data['x'][2:], name='filter2'),  # remaining points
]

# Draw the line based on the data source and view.
p = figure()
renderers = [
    p.line(x='x', y='y', source=source, view=view, name=f'line{i}', muted_alpha=0.2) for i, _ in enumerate(filters)
]

# Create a legend item for each renderer, and attach a callback to the renderer in order to filter it in/out of view when the legend item is clicked.
legend_items = []
for i, r in enumerate(renderers):
    cb = CustomJS(
        args=dict(source=source, view=view, idx_filter=filters[i]),
        code="""
        console.log(cb_obj);
        var visible = cb_obj.visible;
        // The renderer is currently visibile, but it's legend item was clicked, so the user has requested to make it invisible.
        if (visible) {
            const filters = view.filters;
            // Remove filters matching the given filter's name property.
            view.filters = filters.reduce(
                (a, e, i) => (e.name != idx_filter.name) ? a.concat(e) : a, []
            );
        }
        // The renderer is currently invisibile, but it's legend item was clicked, so the user has requested to make it visible.
        else {
            // Add the filter to the view.
            view.filters.push(idx_filter);
        }
        source.change.emit();
        """,
    )
    r.js_on_change('visible', cb)
    legend_item = LegendItem(label=f'line{i}', renderers=[r])
    legend_items.append(legend_item)

# Create the legend.
legend = Legend(
    items=legend_items,
    location='bottom_right',
    click_policy='hide',
)
p.add_layout(legend)
    
show(p)

Callback logic was inverted. Try this:

cb = CustomJS(
    args=dict(source=source, view=view, idx_filter=filters[i]),
    code="""
    console.log(cb_obj);
    // The current state of the renderer is the user's desired state.
    var filter_out_requested = !cb_obj.visible;
    // Add the filter to the view if the renderer is invisible.
    if (filter_out_requested) {
        view.filters.push(idx_filter);
    }
    // Remove the filter from the view if the renderer is visible.
    else {
        const filters = view.filters;
        // Remove filters matching the given filter's name property.
        view.filters = filters.reduce(
            (a, e, i) => (e.name != idx_filter.name) ? a.concat(e) : a, []
        );
    }
    source.change.emit();
    """,
)