Javascript callback for a change of active tool (e.g. RangeTool) in the plot toolbar

Hi all,

I’m building a Bokeh server application which has a plot with a range tool (using the RangeTool class). I would like to change the fill_alpha property of the range tool depending on whether it is active or not.

The idea I had was to use the js_on_change method of the range tool to set up a javascript callback everytime the active property of the range tool changes. However, from what I could see, the range tool only has this property on the javascript side but not on the python side, so something like range_tool.js_on_change('active', callback) in the python code doesn’t work. I also tried looking whether the toolbar object of the plot has a property which gives the currently active tool on the python side, but it seems like it doesn’t.

Any suggestions on how to approach this?

Thanks! Ori

@ori

See the accepted solution posted on this stackoverflow post from October 2019. The workaround there might still be the recommended way to address what you are trying to achieve.

https://stackoverflow.com/questions/58210752/how-to-get-currently-active-tool-in-bokeh-figure

Hi @_jm,

Thanks for the reply. From what I understood from the post you sent, that would be a way to send back the information from javascript to Python. But what I would like to do is to listen to changes in the active property of the range tool, and I don’t see how that could be done with that approach. If I could set up a listener on the javascript side that could change the fill_alpha property every time the active property changes, that would solve the problem. So I actually don’t even need to pass any information to Python. Is it possible to set up such a listener?

@ori

I see. The only thing I can think of as a starting point is to use the MouseMove event, which registers movements over the corresponding plot, including its tools. And check the state of the range-tool active property in that callback.

Here’s a small standalone bokeh file that works to report the active state, starting with the bokeh range-tool example here. https://docs.bokeh.org/en/latest/docs/gallery/range_tool.html

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
"""

import numpy as np

from bokeh.io import show
from bokeh.layouts import column
from bokeh.models import ColumnDataSource, RangeTool
from bokeh.plotting import figure
from bokeh.sampledata.stocks import AAPL

from bokeh.events import MouseMove
from bokeh.models import CustomJS

dates = np.array(AAPL['date'], dtype=np.datetime64)
source = ColumnDataSource(data=dict(date=dates, close=AAPL['adj_close']))

p = figure(plot_height=300, plot_width=800, tools="xpan", toolbar_location=None,
           x_axis_type="datetime", x_axis_location="above",
           background_fill_color="#efefef", x_range=(dates[1500], dates[2500]))

p.line('date', 'close', source=source)
p.yaxis.axis_label = 'Price'

select = figure(title="Drag the middle and edges of the selection box to change the range above",
                plot_height=130, plot_width=800, y_range=p.y_range,
                x_axis_type="datetime", y_axis_type=None,
                tools="", toolbar_location='right', background_fill_color="#efefef")

range_tool = RangeTool(x_range=p.x_range)
range_tool.overlay.fill_color = "navy"
range_tool.overlay.fill_alpha = 0.2

select.line('date', 'close', source=source)
select.ygrid.grid_line_color = None
select.add_tools(range_tool)
select.toolbar.active_multi = range_tool

cb = CustomJS(args=dict(rng=range_tool), code="""
console.log('RANGE TOOL active state ' + rng.active)
""")

select.js_on_event(MouseMove, cb)

show(column(p, select))

Hi @_jm,

Thanks again for the reply. That gives me a partial workaround indeed. However, if the user changes the active tool via the toolbar and does not directly move the mouse over the plot, then the callback will not be called, and the fill_alpha property will not be changed.

Any other suggestions? I wouldn’t mind doing something more low level (like adding some javascript to the template) if that’s needed for the time being.

Cheers, Ori

Hi @ori

If you’re willing to work at a lower level, there is quite a bit more flexibility in what you can achieve, generally speaking.

I’ve on a few occasions had to customize existing bokeh models to support application specific requirements most recently implementing a custom extension to the Bokeh Div model as outlined in the typescript and Python at the end of this Bokeh discourse topic. https://discourse.bokeh.org/t/layout-guidelines-tips-for-rapidly-rendering-many-plots/5119/16.

I think the place to start is the Bokeh user’s documentation section on extending Bokeh, here. https://docs.bokeh.org/en/latest/docs/user_guide/extensions.html

In addition to my example for a Div model, there’s also an example in the Bokeh documentation for extending a plot tool, here, which is at least conceptually a little bit closer to probably what you want.https://docs.bokeh.org/en/latest/docs/user_guide/extensions_gallery/tool.html

At the end, I think it comes down to familiarizing yourself with the particular model that you want to change, writing Typescript or Javascript supporting code, and a Python tool or model class which is typically a pretty minimal interface.

If you have specific questions that the devs or expert users will likely offer valuable help/direction.

Cheers,

J

1 Like

@ori

Another option if you don’t want to get into the lower levels of making a custom tool or modification is to try a periodic callback in Javascript. Something like this as a modification to what was posted earlier in the thread as a starting point.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
"""

import numpy as np

from bokeh.io import show
from bokeh.layouts import column
from bokeh.models import ColumnDataSource, RangeTool
from bokeh.plotting import figure
from bokeh.sampledata.stocks import AAPL

from bokeh.events import MouseMove
from bokeh.models import CustomJS

dates = np.array(AAPL['date'], dtype=np.datetime64)
source = ColumnDataSource(data=dict(date=dates, close=AAPL['adj_close']))

p = figure(plot_height=300, plot_width=800, tools="xpan", toolbar_location=None,
           x_axis_type="datetime", x_axis_location="above",
           background_fill_color="#efefef", x_range=(dates[1500], dates[2500]))

p.line('date', 'close', source=source)
p.yaxis.axis_label = 'Price'

select = figure(title="Drag the middle and edges of the selection box to change the range above",
                plot_height=130, plot_width=800, y_range=p.y_range,
                x_axis_type="datetime", y_axis_type=None,
                tools="", toolbar_location='right', background_fill_color="#efefef")

range_tool = RangeTool(x_range=p.x_range)
range_tool.overlay.fill_color = "navy"
range_tool.overlay.fill_alpha = 0.2

select.line('date', 'close', source=source)
select.ygrid.grid_line_color = None
select.add_tools(range_tool)
select.toolbar.active_multi = range_tool

cb = CustomJS(args=dict(rng=range_tool), code="""
    if (!rng.name) {
        rng.name = 'range_tool';
        setInterval(function(){console.log('RANGE TOOL active state ' + rng.active);}, 1000)
    }
""")

select.js_on_event(MouseMove, cb)

show(column(p, select))

Hi @_jm,

Thanks a lot. The extending Bokeh approach seems to be a solution to what I need, and super useful in general. It will also solve something else I wanted to do which was to change the icon or tooltip of the range tool. If I manage to work it out, I’ll post here a minimal working example.

Cheers, Ori

Hi @_jm (and others…),

Following @_jm’s advice above, I took the RangeTool example (https://docs.bokeh.org/en/latest/docs/gallery/range_tool.html) and I tried extending the RangeTool class to a class that reacts to changes in the active property. As you can see in the code below, I tried two things:

  1. Adding a variable active on the python side that will sync with the active property of the parent Tool class (https://github.com/bokeh/bokeh/blob/branch-2.2/bokehjs/src/lib/models/tools/tool.ts) and then using the on_change method to react to changes.
  2. Overriding the activate and deactivate methods of the Tool class and using that to react to changes in the active property.

Neither of the two worked. Any idea what I need to do to make one of these two methods work? (preferably both).

Here is the code. The main additions are made are marked with “ADDED”.

import numpy as np

from bokeh.io import curdoc
from bokeh.layouts import column
from bokeh.models import ColumnDataSource, RangeTool
from bokeh.plotting import figure
from bokeh.sampledata.stocks import AAPL
from bokeh.util.compiler import TypeScript
from bokeh.core.properties import Bool

TS_CODE = """
import {RangeTool, RangeToolView} from "models/tools/gestures/range_tool"

export class RangeToolCustomView extends RangeToolView {
  model: RangeToolCustom

  # ADDED: override activate and deactivate methods from Tool 
  
  // activate is triggered by toolbar ui actions
  activate(): void {
    console.log("active")
  }

  // deactivate is triggered by toolbar ui actions
  deactivate(): void {
    console.log("non-active")
  }
}

export class RangeToolCustom extends RangeTool {
  __view_type__: RangeToolCustomView
}
"""

class RangeToolCustom(RangeTool):
    __implementation__ = TypeScript(TS_CODE)

    # ADDED: active variable on python side to sync with active variable of Tool on js side
    active = Bool(default=False)

dates = np.array(AAPL['date'], dtype=np.datetime64)
source = ColumnDataSource(data=dict(date=dates, close=AAPL['adj_close']))

p = figure(plot_height=300, plot_width=800, tools="xpan", # toolbar_location=None,
           x_axis_type="datetime", x_axis_location="above",
           background_fill_color="#efefef", x_range=(dates[1500], dates[2500]))

p.line('date', 'close', source=source)
p.yaxis.axis_label = 'Price'

select = figure(title="Drag the middle and edges of the selection box to change the range above",
                plot_height=130, plot_width=800, y_range=p.y_range,
                x_axis_type="datetime", y_axis_type=None,
                tools="xpan", # toolbar_location=None,
                background_fill_color="#efefef")

range_tool = RangeToolCustom(x_range=p.x_range)
range_tool.overlay.fill_color = "navy"
range_tool.overlay.fill_alpha = 0.2

select.line('date', 'close', source=source)
select.ygrid.grid_line_color = None
select.add_tools(range_tool)
select.toolbar.active_multi = range_tool

# ADDED: react to changes in the active variable

def change_active(attr, old, new):
    print("Active changed!")

range_tool.on_change("active", change_active)

curdoc().add_root(column(p, select))

One more issue that I’m having with this code is that in Chrome (but not in Firefox) I get the following error in the Console in Dev Tools:

DevTools failed to load SourceMap: Could not load content for http://localhost:5006/range_tool_custom.py:RangeToolCustom.js.map: HTTP error: status code 404, net::ERR_HTTP_RESPONSE_CODE_FAILURE

Any idea why this is happening? It also happens when I run the Custom Tool example (https://docs.bokeh.org/en/latest/docs/user_guide/extensions_gallery/tool.html#userguide-extensions-examples-tool) using Bokeh Server.

Thanks in advance! Ori