JavaScript 回调函数#

Bokeh 的主要目标是提供一种途径,以便纯粹从 Python 创建丰富的交互式浏览器可视化效果。但是,总有一些用例超出了预定义核心库的功能。

出于这个原因,Bokeh 提供了不同的方法供用户在必要时提供自定义 JavaScript。这样,您就可以根据浏览器中属性更改和其他事件添加自定义或主题行为。

注意

顾名思义,JavaScript 回调函数是在浏览器中执行的 JavaScript 代码片段。如果您正在寻找仅基于 Python 并且可以使用 Bokeh 服务器运行的交互式回调函数,请参阅 Python 回调函数

主要有三种 **生成 JavaScript 回调函数的选项**

  • 使用 js_link Python 方便方法。此方法可帮助您将不同模型的属性链接在一起。使用此方法,Bokeh 会自动为您创建必要的 JavaScript 代码。有关详细信息,请参阅 链接行为

  • 使用 SetValue Python 对象根据另一个对象的特定事件动态设置一个对象的属性。有关更多信息,请参阅 SetValue 回调函数

  • 使用 CustomJS 对象编写自定义 JavaScript 代码。有关更多信息,请参阅 CustomJS 回调函数

当浏览器中发生某些事件时,将触发 JavaScript 回调函数。主要有两种 **JavaScript 回调函数触发器类型**

  • 大多数 Bokeh 对象都具有 .js_on_change 属性(例如,所有 部件)。每当对象的状态发生变化时,分配给此属性的回调函数都将被调用。有关更多信息,请参阅 js_on_change 回调函数触发器

  • 一些 部件 还具有 .js_on_event 属性。每当浏览器中发生特定事件时,分配给此属性的回调函数都将被调用。

警告

CustomJS 模型的明确目的是嵌入供浏览器执行的原始 JavaScript 代码。如果代码的任何部分来自不受信任的用户输入,那么您 **必须在将用户输入传递给 Bokeh 之前采取适当的措施对其进行清理**。

此外,您可以通过编写自己的 Bokeh 扩展 添加整个新的自定义扩展模型。

SetValue 回调函数#

使用 SetValue 模型在浏览器中发生事件时动态设置对象的特定属性。

SetValue 模型具有以下属性

  • obj:要设置值的物体。

  • attr:要修改的对象的属性。

  • value:要为对象的属性设置的值。

基于这些参数,Bokeh 会自动创建必要的 JavaScript 代码

from bokeh.io import show
from bokeh.models import Button, SetValue

button = Button(label="Foo", button_type="primary")
callback = SetValue(obj=button, attr="label", value="Bar")
button.js_on_event("button_click", callback)

show(button)

CustomJS 回调函数#

使用 CustomJS 模型提供自定义 JavaScript 代码片段,以便在发生事件时在浏览器中运行。

from bokeh.models.callbacks import CustomJS

callback = CustomJS(args=dict(xr=plot.x_range, yr=plot.y_range, slider=slider), code="""
// imports
import {some_function, SOME_VALUE} from "https://cdn.jsdelivr.net.cn/npm/package@version/file"

// constants, definitions and state
const MY_VALUE = 3.14

function my_function(value) {
    return MY_VALUE*value
}

class MyClass {
    constructor(value) {
        this.value = value
    }
}

let count = 0

// the callback function
export default (args, obj, data, context) => {
    count += 1
    console.log(`CustomJS was called ${count} times`)

    const a = args.slider.value
    const b = obj.value

    const {xr, yr} = args
    xr.start = my_function(a)
    xr.end = b
}
""")

代码片段必须包含一个默认导出,该导出必须是使用箭头函数语法 () => {} 或经典函数语法 function() {} 定义的函数。根据上下文,此函数可能是异步函数、生成器函数或异步生成器函数。同样根据上下文,此函数可能需要或不需要返回值。

回调函数使用四个位置参数

  • args

    这映射到 CustomJS.args 属性,允许将名称映射到可序列化值,通常提供对代码片段中 Bokeh 模型的访问。

  • obj

    这指的是发出回调的模型(这是回调附加到的模型)。

  • data

    这是此回调的发出者提供的名称和值之间的映射。这取决于调用者、事件以及事件发生的上下文。例如,选择工具将使用 data 提供选择几何体等。

  • context

    这是 bokehjs 提供的另一个更广泛的上下文,类似于 data,它是名称和值之间的映射。目前仅提供 index,它允许用户访问 bokehjs 的视图索引。

对用户来说,使用对象解构语法来立即访问传递的值可能很方便,例如

from bokeh.models.callbacks import CustomJS

callback = CustomJS(args=dict(xr=plot.x_range, yr=plot.y_range, slider=slider), code="""
export default ({xr, yr, slider}, obj, {geometry}, {index}) => {
    // use xr, yr, slider, geometry and index
}
""")

代码片段编译一次,回调函数(默认导出)可以多次求值。这样,用户可以稳健有效地导入外部库、定义复杂的类和数据结构以及在回调函数调用之间维护状态。仅当 CustomJS 实例的属性发生更改时,才会重新编译代码片段。

或者,用户可以使用 CustomJS 的旧版本,其中代码片段是隐式回调函数的主体

from bokeh.models.callbacks import CustomJS

callback = CustomJS(args=dict(xr=plot.x_range), code="""
// JavaScript code goes here
const a = 10

// the model that triggered the callback is cb_obj:
const b = cb_obj.value

// models passed as args are auto-magically available
xr.start = a
xr.end = b
""")

Bokeh 通过检测代码片段中 importexport 语法的存在与否来区分这两种方法。

在这种方法中,回调函数的参数是隐式定义的。由 CustomJS.args 提供的名称立即作为位置参数可用,而 objdatacontext 都可以使用 cb_ 前缀获得,即 cb_objcb_datacb_context

最后,用户可以从文件创建 CustomJS,这在处理大型和/或复杂代码片段时很有用

from bokeh.models.callbacks import CustomJS

callback = CustomJS.from_file("./my_module.mjs", xr=plot.x_range)

允许的扩展名是

  • .mjs 用于新的 export default () => {} 变体

  • .js 用于旧版 CustomJS

js_on_change 回调函数触发器#

CustomJSSetValue 回调函数可以附加到任何 Bokeh 模型上的属性更改事件,使用 Bokeh 模型的 js_on_change 方法

p = figure()

# execute a callback whenever p.x_range.start changes
p.x_range.js_on_change('start', callback)

以下示例将 CustomJS 回调函数附加到 Slider 部件。每当滑块值更新时,回调函数都会使用自定义公式更新绘图数据

from bokeh.layouts import column
from bokeh.models import ColumnDataSource, CustomJS, Slider
from bokeh.plotting import figure, show

x = [x*0.005 for x in range(0, 200)]
y = x

source = ColumnDataSource(data=dict(x=x, y=y))

plot = figure(width=400, height=400, x_range=(0, 1), y_range=(0, 1))

plot.line('x', 'y', source=source, line_width=3, line_alpha=0.6)

callback = CustomJS(args=dict(source=source), code="""
    const f = cb_obj.value
    const x = source.data.x
    const y = Array.from(x, (x) => Math.pow(x, f))
    source.data = { x, y }
""")

slider = Slider(start=0.1, end=4, value=1, step=.1, title="power")
slider.js_on_change('value', callback)

layout = column(slider, plot)

show(layout)

js_on_event 回调函数触发器#

除了使用 js_on_change 响应属性更改事件外,Bokeh 还允许通过与绘图画布的特定交互事件、按钮点击事件、LOD(细节层次)事件和文档事件来触发 CustomJSSetValue 回调。

这些事件回调是在模型上使用 js_on_event 方法定义的,回调接收事件对象作为局部定义的 cb_obj 变量。

from bokeh.models.callbacks import CustomJS

callback = CustomJS(code="""
// the event that triggered the callback is cb_obj:
// The event type determines the relevant attributes
console.log('Tap event occurred at x-position: ' + cb_obj.x)
""")

p = figure()
# execute a callback whenever the plot canvas is tapped
p.js_on_event('tap', callback)

事件可以指定为字符串,例如上面的 'tap',或者来自 bokeh.events 模块的事件类导入(即 from bokeh.events import Tap)。

以下代码导入 bokeh.events 并使用 display_event 函数注册所有可用的事件类,以生成 CustomJS 对象。此函数用于使用事件名称(始终可以通过 event_name 属性访问)以及所有其他适用的事件属性更新 Div。结果是一个绘图,当用户与其交互时,在右侧显示相应的事件。

from __future__ import annotations

import numpy as np

from bokeh import events
from bokeh.io import curdoc, show
from bokeh.layouts import column, row
from bokeh.models import Button, CustomJS, Div, TextInput
from bokeh.plotting import figure


def display_event(div: Div, attributes: list[str] = []) -> CustomJS:
    """
    Function to build a suitable CustomJS to display the current event
    in the div model.
    """
    style = 'float: left; clear: left; font-size: 13px'
    return CustomJS(args=dict(div=div), code=f"""
        const attrs = {attributes};
        const args = [];
        for (let i = 0; i < attrs.length; i++) {{
            const val = JSON.stringify(cb_obj[attrs[i]], function(key, val) {{
                return val.toFixed ? Number(val.toFixed(2)) : val;
            }})
            args.push(attrs[i] + '=' + val)
        }}
        const line = "<span style={style!r}><b>" + cb_obj.event_name + "</b>(" + args.join(", ") + ")</span>\\n";
        const text = div.text.concat(line);
        const lines = text.split("\\n")
        if (lines.length > 35)
            lines.shift();
        div.text = lines.join("\\n");
    """)

N = 4000
x = np.random.random(size=N) * 100
y = np.random.random(size=N) * 100
radii = np.random.random(size=N) * 1.5
colors = np.array([(r, g, 150) for r, g in zip(50+2*x, 30+2*y)], dtype="uint8")

p = figure(tools="pan,wheel_zoom,zoom_in,zoom_out,reset,tap,lasso_select,box_select,box_zoom,undo,redo")

p.circle(x, y, radius=radii, fill_color=colors, fill_alpha=0.6, line_color=None)

# Add a div to display events and a button to trigger button click events

div = Div(width=1000)
button = Button(label="Button", button_type="success", width=300)
text_input = TextInput(placeholder="Input a value and press Enter ...", width=300)
layout = column(button, text_input, row(p, div))

# Register event callbacks

# Button events
button.js_on_event(events.ButtonClick, display_event(div))

# TextInput events
text_input.js_on_event(events.ValueSubmit, display_event(div, ["value"]))

# AxisClick events
p.xaxis[0].js_on_event(events.AxisClick, display_event(div, ["value"]))
p.yaxis[0].js_on_event(events.AxisClick, display_event(div, ["value"]))

# LOD events
p.js_on_event(events.LODStart, display_event(div))
p.js_on_event(events.LODEnd, display_event(div))

# Point events
point_attributes = ['x','y','sx','sy']
p.js_on_event(events.Tap,       display_event(div, attributes=point_attributes))
p.js_on_event(events.DoubleTap, display_event(div, attributes=point_attributes))
p.js_on_event(events.Press,     display_event(div, attributes=point_attributes))
p.js_on_event(events.PressUp,   display_event(div, attributes=point_attributes))

# Mouse wheel event
p.js_on_event(events.MouseWheel, display_event(div,attributes=[*point_attributes, 'delta']))

# Mouse move, enter and leave
# p.js_on_event(events.MouseMove,  display_event(div, attributes=point_attributes))
p.js_on_event(events.MouseEnter, display_event(div, attributes=point_attributes))
p.js_on_event(events.MouseLeave, display_event(div, attributes=point_attributes))

# Pan events
pan_attributes = [*point_attributes, 'delta_x', 'delta_y']
p.js_on_event(events.Pan,      display_event(div, attributes=pan_attributes))
p.js_on_event(events.PanStart, display_event(div, attributes=point_attributes))
p.js_on_event(events.PanEnd,   display_event(div, attributes=point_attributes))

# Pinch events
pinch_attributes = [*point_attributes, 'scale']
p.js_on_event(events.Pinch,      display_event(div, attributes=pinch_attributes))
p.js_on_event(events.PinchStart, display_event(div, attributes=point_attributes))
p.js_on_event(events.PinchEnd,   display_event(div, attributes=point_attributes))

# Ranges Update events
p.js_on_event(events.RangesUpdate, display_event(div, attributes=['x0','x1','y0','y1']))

# Selection events
p.js_on_event(events.SelectionGeometry, display_event(div, attributes=['geometry', 'final']))

curdoc().on_event(events.DocumentReady, display_event(div))

show(layout)

文档事件的 JS 回调可以使用 Document.js_on_event() 方法注册。在独立嵌入模式的情况下,将使用 curdoc() 通过当前文档来设置此类回调。例如:

from bokeh.models import Div
from bokeh.models.callbacks import CustomJS
from bokeh.io import curdoc, show

div = Div()
# execute a callback when the document is fully rendered
callback = CustomJS(args=dict(div=div, code="""div.text = "READY!"""")
curdoc().js_on_event("document_ready", callback)
show(div)

类似于模型级别的 JS 事件,也可以使用事件类代替事件名称来注册文档事件回调。

from bokeh.events import DocumentReady
curdoc().js_on_event(DocumentReady, callback)

示例#

Widget 的 CustomJS#

属性回调的一个常见用例是对小部件更改做出响应。以下代码显示了在滑块小部件上设置 CustomJS 的示例,该滑块在使用滑块时更改绘图的源。

from bokeh.layouts import column
from bokeh.models import ColumnDataSource, CustomJS, Slider
from bokeh.plotting import figure, show

x = [x*0.005 for x in range(0, 200)]
y = x

source = ColumnDataSource(data=dict(x=x, y=y))

plot = figure(width=400, height=400, x_range=(0, 1), y_range=(0, 1))

plot.line('x', 'y', source=source, line_width=3, line_alpha=0.6)

callback = CustomJS(args=dict(source=source), code="""
    const f = cb_obj.value
    const x = source.data.x
    const y = Array.from(x, (x) => Math.pow(x, f))
    source.data = { x, y }
""")

slider = Slider(start=0.1, end=4, value=1, step=.1, title="power")
slider.js_on_change('value', callback)

show(column(slider, plot))

选择项的 CustomJS#

另一种常见的情况是希望指定每当选择发生更改时执行的相同类型的回调。作为一个简单的演示,下面的示例只是将第一个绘图上的选定点复制到第二个绘图。但是,更复杂的动作和计算可以以类似的方式轻松构建。

from random import random

from bokeh.layouts import row
from bokeh.models import ColumnDataSource, CustomJS
from bokeh.plotting import figure, show

x = [random() for x in range(500)]
y = [random() for y in range(500)]

s1 = ColumnDataSource(data=dict(x=x, y=y))
p1 = figure(width=400, height=400, tools="lasso_select", title="Select Here")
p1.scatter('x', 'y', source=s1, alpha=0.6)

s2 = ColumnDataSource(data=dict(x=[], y=[]))
p2 = figure(width=400, height=400, x_range=(0, 1), y_range=(0, 1),
            tools="", title="Watch Here")
p2.scatter('x', 'y', source=s2, alpha=0.6)

s1.selected.js_on_change('indices', CustomJS(args=dict(s1=s1, s2=s2), code="""
        const inds = cb_obj.indices
        const d1 = s1.data
        const x = Array.from(inds, (i) => d1.x[i])
        const y = Array.from(inds, (i) => d1.y[i])
        s2.data = {x, y}
    """),
)

layout = row(p1, p2)

show(layout)

下面显示了另一个更复杂的示例。它计算任何选定点(包括多个不连续的选择)的平均 y 值,并在该值处绘制一条线。

from random import random

from bokeh.models import ColumnDataSource, CustomJS
from bokeh.plotting import figure, show

x = [random() for x in range(500)]
y = [random() for y in range(500)]
s = ColumnDataSource(data=dict(x=x, y=y))

p = figure(width=400, height=400, tools="lasso_select", title="Select Here")
p.scatter('x', 'y', color='navy', size=8, source=s, alpha=0.4,
          selection_color="firebrick")

s2 = ColumnDataSource(data=dict(x=[0, 1], ym=[0.5, 0.5]))
p.line(x='x', y='ym', color="orange", line_width=5, alpha=0.6, source=s2)

s.selected.js_on_change('indices', CustomJS(args=dict(s=s, s2=s2), code="""
    const inds = s.selected.indices
    if (inds.length > 0) {
        const ym = inds.reduce((a, b) => a + s.data.y[b], 0) / inds.length
        s2.data = { x: s2.data.x, ym: [ym, ym] }
    }
"""))

show(p)

范围的 CustomJS#

范围对象的属性也可以连接到 CustomJS 回调,以便在范围发生更改时执行主题工作。

import numpy as np

from bokeh.layouts import row
from bokeh.models import BoxAnnotation, CustomJS
from bokeh.plotting import figure, show

N = 4000

x = np.random.random(size=N) * 100
y = np.random.random(size=N) * 100
radii = np.random.random(size=N) * 1.5
colors = np.array([(r, g, 150) for r, g in zip(50+2*x, 30+2*y)], dtype="uint8")

box = BoxAnnotation(left=0, right=0, bottom=0, top=0,
    fill_alpha=0.1, line_color='black', fill_color='black')

jscode = """
    box[%r] = cb_obj.start
    box[%r] = cb_obj.end
"""

p1 = figure(title='Pan and Zoom Here', x_range=(0, 100), y_range=(0, 100),
            tools='box_zoom,wheel_zoom,pan,reset', width=400, height=400)
p1.circle(x, y, radius=radii, fill_color=colors, fill_alpha=0.6, line_color=None)

xcb = CustomJS(args=dict(box=box), code=jscode % ('left', 'right'))
ycb = CustomJS(args=dict(box=box), code=jscode % ('bottom', 'top'))

p1.x_range.js_on_change('start', xcb)
p1.x_range.js_on_change('end', xcb)
p1.y_range.js_on_change('start', ycb)
p1.y_range.js_on_change('end', ycb)

p2 = figure(title='See Zoom Window Here', x_range=(0, 100), y_range=(0, 100),
            tools='', width=400, height=400)
p2.circle(x, y, radius=radii, fill_color=colors, fill_alpha=0.6, line_color=None)
p2.add_layout(box)

layout = row(p1, p2)

show(layout)

工具的 CustomJS#

选择工具会发出可以驱动有用回调的事件。下面,SelectionGeometry 的回调使用 BoxSelectTool 几何体(通过 cb_data 回调对象的 geometry 字段访问),以更新 Rect 标记。

from bokeh.events import SelectionGeometry
from bokeh.models import ColumnDataSource, CustomJS, Quad
from bokeh.plotting import figure, show

source = ColumnDataSource(data=dict(left=[], right=[], top=[], bottom=[]))

callback = CustomJS(args=dict(source=source), code="""
    const geometry = cb_obj.geometry
    const data = source.data

    // quad is forgiving if left/right or top/bottom are swapped
    source.data = {
        left: data.left.concat([geometry.x0]),
        right: data.right.concat([geometry.x1]),
        top: data.top.concat([geometry.y0]),
        bottom: data.bottom.concat([geometry.y1])
    }
""")

p = figure(width=400, height=400, title="Select below to draw rectangles",
           tools="box_select", x_range=(0, 1), y_range=(0, 1))

# using Quad model directly to control (non)selection glyphs more carefully
quad = Quad(left='left', right='right', top='top', bottom='bottom',
            fill_alpha=0.3, fill_color='#009933')

p.add_glyph(
    source,
    quad,
    selection_glyph=quad.clone(fill_color='blue'),
    nonselection_glyph=quad.clone(fill_color='gray'),
)

p.js_on_event(SelectionGeometry, callback)

show(p)

主题事件的 CustomJS#

除了上面描述的将 CustomJS 回调添加到 Bokeh 模型的通用机制外,还有一些 Bokeh 模型具有 .callback 属性,专门用于响应特定事件或情况执行 CustomJS

警告

下面描述的回调是在 Bokeh 中以一种临时的方式添加的。其中许多可以通过上面描述的通用机制来实现,因此,将来可能会弃用它们,转而使用通用机制。

悬停工具的 CustomJS#

HoverTool 具有一个回调,它带有两部分内置数据:indexgeometryindex 是悬停工具悬停在任何点上的索引。

from bokeh.models import ColumnDataSource, CustomJS, HoverTool
from bokeh.plotting import figure, show

# define some points and a little graph between them
x = [2, 3, 5, 6, 8, 7]
y = [6, 4, 3, 8, 7, 5]
links = {
    0: [1, 2],
    1: [0, 3, 4],
    2: [0, 5],
    3: [1, 4],
    4: [1, 3],
    5: [2, 3, 4],
}

p = figure(width=400, height=400, tools="", toolbar_location=None, title='Hover over points')

source = ColumnDataSource(dict(x0=[], y0=[], x1=[], y1=[]))
sr = p.segment(x0='x0', y0='y0', x1='x1', y1='y1', color='olive', alpha=0.6, line_width=3, source=source )
cr = p.scatter(x, y, color='olive', size=30, alpha=0.4, hover_color='olive', hover_alpha=1.0)

# add a hover tool that sets the link data for a hovered circle
code = """
const data = {x0: [], y0: [], x1: [], y1: []}
const {indices} = cb_data.index
for (const start of indices) {
    for (const end of links.get(start)) {
        data.x0.push(circle.data.x[start])
        data.y0.push(circle.data.y[start])
        data.x1.push(circle.data.x[end])
        data.y1.push(circle.data.y[end])
    }
}
segment.data = data
"""

callback = CustomJS(args=dict(circle=cr.data_source, segment=sr.data_source, links=links), code=code)
p.add_tools(HoverTool(tooltips=None, callback=callback, renderers=[cr]))

show(p)

OpenURL#

当用户点击标记(例如圆形标记)时打开 URL 是一项非常受欢迎的功能。Bokeh 允许用户通过公开一个 OpenURL 回调对象来启用此功能,该对象可以传递给 Tap 工具,以便在用户点击标记时调用该操作。

以下代码显示了如何使用 OpenURL 操作结合 TapTool,在用户点击圆形时打开 URL。

from bokeh.models import ColumnDataSource, OpenURL, TapTool
from bokeh.plotting import figure, show

p = figure(width=400, height=400,
           tools="tap", title="Click the Dots")

source = ColumnDataSource(data=dict(
    x=[1, 2, 3, 4, 5],
    y=[2, 5, 8, 2, 7],
    color=["navy", "orange", "olive", "firebrick", "gold"],
    ))

p.scatter('x', 'y', color='color', size=20, source=source)

# use the "color" column of the CDS to complete the URL
# e.g. if the glyph at index 10 is selected, then @color
# will be replaced with source.data['color'][10]
url = "https://www.html-color-names.com/@color.php"
taptool = p.select(type=TapTool)
taptool.callback = OpenURL(url=url)

show(p)

请注意,OpenURL 回调专门且仅适用于 TapTool,并且仅在点击标记时才会调用。也就是说,它们不会在每次点击时都执行。如果您想在每次鼠标点击时执行回调,请参阅 js_on_event 回调触发器