Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第六章:交互功能场景实战

前置知识:本章需要掌握 ColumnDataSource(第二章 2.2)、Figure 工具配置(第二章 2.3)和基础图表(第三章)。

6.1 场景一:悬停查看详情

需求:鼠标悬停在数据点上,显示详细信息。

实现

from bokeh.models import HoverTool, ColumnDataSource

source = ColumnDataSource(data={
    'x': [1, 2, 3, 4, 5],
    'y': [2, 5, 8, 2, 7],
    'product': ['A', 'B', 'C', 'D', 'E'],
    'sales': [100, 200, 150, 80, 250],
})

p = figure(tools="", width=600, height=400)
p.circle('x', 'y', source=source, size=15)

# 添加悬停工具
hover = HoverTool(tooltips=[
    ("产品", "@product"),
    ("销量", "@sales{0,0}"),  # 千分位格式
    ("坐标", "(@x, @y)"),
])
p.add_tools(hover)

高级悬停模板

hover = HoverTool(tooltips="""
    <div style="background: #333; color: white; padding: 10px; border-radius: 5px;">
        <h3>@product</h3>
        <p>销量: @sales{0,0}</p>
        <p>占比: @percent{0.0%}</p>
    </div>
""")

6.2 场景二:选择联动

需求:在图表A中选择数据点,图表B高亮显示对应数据。

实现

from bokeh.models import ColumnDataSource
from bokeh.layouts import row

# 共享数据源
source = ColumnDataSource(data={
    'x': [1, 2, 3, 4, 5],
    'y': [2, 5, 8, 2, 7],
    'category': ['A', 'B', 'C', 'D', 'E'],
})

# 图表1
p1 = figure(tools="box_select,lasso_select", width=400, height=400)
p1.circle('x', 'y', source=source, size=15,
    selection_color="red", nonselection_color="gray")

# 图表2 - 使用相同数据源
p2 = figure(width=400, height=400)
p2.circle('x', 'y', source=source, size=15,
    selection_color="red", nonselection_color="gray")

# 选择自动联动(因为共享数据源)
show(row(p1, p2))

6.3 场景三:动态控制

需求:通过滑块控制显示的数据范围。

CustomJS实现(纯前端)

from bokeh.models import Slider, CustomJS

source = ColumnDataSource(data={
    'x': list(range(100)),
    'y': [i**0.5 for i in range(100)],
})

p = figure(width=600, height=400)
p.line('x', 'y', source=source)

slider = Slider(start=0, end=99, value=99, title="显示点数")

# JavaScript回调
slider.js_on_change('value', CustomJS(args=dict(source=source), code="""
    const data = source.data;
    const n = cb_obj.value;
    source.change.emit();
"""))

show(column(slider, p))

6.4 场景四:点击事件

需求:点击数据点,显示详细信息面板。

实现

from bokeh.models import TapTool, CustomJS

source = ColumnDataSource(data={
    'x': [1, 2, 3, 4, 5],
    'y': [2, 5, 8, 2, 7],
    'name': ['A', 'B', 'C', 'D', 'E'],
    'info': ['详情A', '详情B', '详情C', '详情D', '详情E'],
})

p = figure(tools="tap", width=600, height=400)
renderers = p.circle('x', 'y', source=source, size=20)

# 点击回调
tap = p.select(type=TapTool)
tap.callback = CustomJS(args=dict(source=source), code="""
    const indices = source.selected.indices;
    if (indices.length > 0) {
        const idx = indices[0];
        alert('选中: ' + source.data['name'][idx] + '\\n' + source.data['info'][idx]);
    }
""")

6.5 工具详解

工具分类

类别工具说明
导航PanTool, WheelZoomTool, BoxZoomTool平移缩放
选择BoxSelectTool, LassoSelectTool, PolySelectTool区域选择
检查HoverTool, CrosshairTool, InspectTool数据检查
编辑PointDrawTool, BoxEditTool, PolyDrawTool数据编辑
操作ResetTool, SaveTool, UndoTool, RedoTool操作工具

添加工具

# 方式1:字符串
p = figure(tools="pan,wheel_zoom,box_zoom,reset,save")

# 方式2:工具对象
from bokeh.models import HoverTool, BoxSelectTool
p = figure(tools=[HoverTool(), BoxSelectTool()])

# 方式3:动态添加
p.add_tools(HoverTool(tooltips=[("x", "@x")]))

配置工具

from bokeh.models import BoxSelectTool

p = figure(tools="box_select")
box_select = p.select(type=BoxSelectTool)
box_select.dimensions = "width"  # 只能水平选择
box_select.select_every_mousemove = False

6.6 链接轴与链接刷选

链接轴(共享坐标轴)

# 共享x轴
p1 = figure(width=400, height=300)
p2 = figure(width=400, height=300, x_range=p1.x_range)  # 共享x轴

# 现在缩放p1,p2会同步

链接刷选(共享数据源)

source = ColumnDataSource(data={...})

p1 = figure()
p1.circle('x', 'y', source=source)

p2 = figure()
p2.circle('a', 'b', source=source)  # 同一数据源

# 选择任一图表,另一个同步高亮

6.7 CustomJS 回调

基础 CustomJS 回调

from bokeh.models import CustomJS, Slider

slider = Slider(start=0, end=10, value=1, title="Size")

# 定义 JavaScript 回调
callback = CustomJS(args=dict(source=source), code="""
    const data = source.data;
    const size = cb_obj.value;
    data['size'] = data['size'].map(() => size);
    source.change.emit();
""")

slider.js_on_change('value', callback)

⚠️ 重要提示:Python 回调(on_changeon_event只能在 Bokeh Server 中运行。在 show()save() 模式下使用 Python 回调不会报错,但回调函数不会执行。如果需要纯前端交互,请使用 CustomJS。Bokeh Server 的完整用法见第七章

Python 回调(Bokeh Server)

运行要求:下面的 on_change() 示例必须放在 Bokeh Server 应用中执行,例如保存为 app.py 后运行 bokeh serve app.py --show。如果只是用 show() / save() 生成 standalone HTML,请改用上面的 CustomJS / js_on_change() 写法。关于 Bokeh Server 应用结构、curdoc() 和部署方式,详见第七章;如果你的回调主要是在更新 ColumnDataSource,也建议回看第二章 2.3的数据更新方式。

from bokeh.models import Slider

def update_data(attr, old, new):
    # Python 回调函数
    source.data = compute_new_data(new)

slider = Slider(start=0, end=10, value=1)
slider.on_change('value', update_data)

6.8 自定义工具

from bokeh.models import CustomAction

custom_action = CustomAction(
    name="Custom Action",
    callback=CustomJS(code="console.log('Custom action triggered')")
)
p.add_tools(custom_action)

6.9 交互设计模式

在实际项目中,交互功能往往不是孤立使用的,而是按照一定的设计模式组合,以满足特定的分析需求。本节介绍五种常见的交互设计模式及其在 Bokeh 中的实现方式。

以下模式共享相同的基础导入,不再在每个示例中重复:

# 所有模式的公共模板
from bokeh.models import ColumnDataSource, CustomJS, HoverTool
from bokeh.plotting import figure, show
from bokeh.layouts import row, column
from bokeh.io import output_notebook
import numpy as np

output_notebook()

主从联动模式(Master-Detail)

概念:用户在一个“主“视图中选择数据子集,“从“视图立即更新以展示选中数据的详细信息。这是仪表盘中最为经典的交互模式之一。

适用场景

  • 概览-详情浏览(如地图上选区域,旁边显示该区域详细数据)
  • 列表-详情展示(如点击表格行,右侧展示完整信息)

核心代码——关键在于两个数据源 + selected 事件回调,将选中行复制到从数据源:

# 两个数据源:主视图全量数据,从视图初始为空
master_source = ColumnDataSource(data={
    'x': np.random.randn(200), 'y': np.random.randn(200),
    'sales': np.random.randint(50, 500, 200),
    'profit': np.random.uniform(0.05, 0.35, 200),
})
detail_source = ColumnDataSource(data={'sales': [], 'profit': []})

# 主视图(启用 box_select / lasso_select 工具)
master_plot = figure(width=500, height=400, title="主视图:框选数据",
                    tools="box_select,lasso_select,reset,wheel_zoom")
master_plot.circle('x', 'y', source=master_source, size=8, alpha=0.6,
                   selection_color='orange', nonselection_alpha=0.2)

# 从视图
detail_plot = figure(width=400, height=400, title="从视图:选中数据详情")
detail_plot.circle('sales', 'profit', source=detail_source, size=10, color='orange')

# ★ 核心:选中事件回调——将选中索引的数据复制到 detail_source
master_source.selected.js_on_change('indices', CustomJS(
    args=dict(master=master_source, detail=detail_source),
    code="""
    const indices = master.selected.indices;
    const d = detail.data;
    d['sales'] = [];
    d['profit'] = [];
    for (let i = 0; i < indices.length; i++) {
        const idx = indices[i];
        d['sales'].push(master.data['sales'][idx]);
        d['profit'].push(master.data['profit'][idx]);
    }
    detail.change.emit();
    """
))

show(row(master_plot, detail_plot))

过滤钻取模式(Filter & Drill-down)

概念:通过级联过滤器,逐层缩小数据范围,最终定位到感兴趣的数据子集。类似于数据库查询中的 WHERE 子句逐步叠加。

适用场景

  • 多维度数据分析(如地区→城市→门店逐级下钻)
  • 过滤条件之间存在依赖关系(级联动)

核心代码——关键在于 Select 控件级联 + CustomJS 过滤逻辑:

from bokeh.models import Select

# 层级数据
regions_cities = {
    '华东': ['上海', '杭州', '南京'],
    '华北': ['北京', '天津', '石家庄'],
}
products = ['手机', '电脑', '平板']

# 构造扁平数据 + 全量/过滤 两个数据源
data = {'region': [], 'city': [], 'product': [], 'sales': []}
for region, cities in regions_cities.items():
    for city in cities:
        for product in products:
            data['region'].append(region)
            data['city'].append(city)
            data['product'].append(product)
            data['sales'].append(np.random.randint(100, 1000))

full_source = ColumnDataSource(data=data)
filtered_source = ColumnDataSource(data={k: list(v) for k, v in data.items()})

# 三个级联 Select
region_select = Select(title="地区:", value='全部',
                       options=['全部'] + list(regions_cities.keys()))
city_select = Select(title="城市:", value='全部',
                     options=['全部'] + sum(regions_cities.values(), []))
product_select = Select(title="产品:", value='全部', options=['全部'] + products)

# 图表
p = figure(width=700, height=350, x_range=products, tools="hover,wheel_zoom,reset")
p.vbar(x='product', top='sales', source=filtered_source, width=0.5)

# ★ 核心:级联过滤回调——根据三个 Select 的值过滤数据,并动态更新城市选项
filter_callback = CustomJS(
    args=dict(full=full_source, filtered=filtered_source,
              region_sel=region_select, city_sel=city_select,
              product_sel=product_select, regions_cities=regions_cities),
    code="""
    const region = region_sel.value;
    const product = product_sel.value;

    // 级联:更新城市下拉框选项
    let cities = ['全部'];
    if (region !== '全部' && regions_cities[region]) {
        cities = cities.concat(regions_cities[region]);
    } else {
        cities = cities.concat(Object.values(regions_cities).flat());
    }
    city_sel.options = cities;
    if (!cities.includes(city_sel.value)) city_sel.value = '全部';
    const city = city_sel.value;

    // 过滤数据
    const fd = filtered.data, sd = full.data;
    for (const key of Object.keys(fd)) fd[key] = [];
    for (let i = 0; i < sd['region'].length; i++) {
        if ((region === '全部' || sd['region'][i] === region) &&
            (city === '全部' || sd['city'][i] === city) &&
            (product === '全部' || sd['product'][i] === product)) {
            for (const key of Object.keys(fd)) fd[key].push(sd[key][i]);
        }
    }
    filtered.change.emit();
    """
)

for sel in [region_select, city_select, product_select]:
    sel.js_on_change('value', filter_callback)

show(column(region_select, city_select, product_select, p))

参数控制模式(Parameter Control)

概念:通过滑块、输入框等控件,让用户动态调整可视化参数(如大小、透明度、颜色范围),图表实时响应。这是最简洁的模式——回调仅修改 renderer.glyph 的属性。

适用场景

  • 参数敏感性分析(如调整阈值观察分类结果变化)
  • 自定义视图外观(如调整点大小、透明度)

核心代码——仅两行回调,直接修改 glyph 属性:

from bokeh.models import Slider, ColorBar
from bokeh.transform import linear_cmap
from bokeh.palettes import Viridis256

source = ColumnDataSource(data={
    'x': np.random.randn(300), 'y': np.random.randn(300),
    'value': np.random.uniform(0, 100, 300),
})

mapper = linear_cmap('value', Viridis256, low=0, high=100)
p = figure(width=600, height=400, tools="wheel_zoom,pan,reset,hover")
renderer = p.circle('x', 'y', source=source, size=8, alpha=0.6, color=mapper)

point_size = Slider(start=2, end=30, value=8, step=1, title="点大小")
alpha_slider = Slider(start=0.1, end=1.0, value=0.6, step=0.05, title="透明度")

# ★ 核心:直接修改 renderer.glyph 属性,无需重绘数据
point_size.js_on_change('value', CustomJS(
    args=dict(renderer=renderer), code="renderer.glyph.size = cb_obj.value;"))
alpha_slider.js_on_change('value', CustomJS(
    args=dict(renderer=renderer), code="renderer.glyph.alpha = cb_obj.value;"))

show(column(row(point_size, alpha_slider), p))

实时监控模式(Real-time Monitoring)

概念:数据通过定时更新或流式传输不断涌入,图表自动刷新以展示最新状态。此模式必须使用 Bokeh Servercurdoc()),纯前端 show() 无法运行定时器。

适用场景

  • 系统监控(CPU、内存、网络流量)
  • 传感器数据或业务指标实时看板

核心代码——需要 bokeh serve 运行,关键在于 periodic_callback + source.stream()

# 文件名:realtime_monitor.py
# 运行:bokeh serve realtime_monitor.py --show

from bokeh.plotting import figure, curdoc
from bokeh.models import ColumnDataSource
from bokeh.layouts import column
from datetime import datetime

MAX_POINTS = 100
source = ColumnDataSource(data={'time': [], 'value': [], 'value2': [], 'mean': []})

p = figure(width=800, height=400, title="实时监控面板",
           x_axis_type="datetime", tools="pan,wheel_zoom,reset,hover,crosshair")
p.line('time', 'value', source=source, color='steelblue', line_width=2, legend_label="指标A")
p.line('time', 'value2', source=source, color='coral', line_width=2, legend_label="指标B")
p.line('time', 'mean', source=source, color='green', line_dash='dashed', legend_label="移动平均")
p.legend.location = "top_left"

counter = [0]

def update():
    """★ 核心:定时回调——用 stream() 追加数据,自动滚动"""
    counter[0] += 1
    t = counter[0] * 0.1
    now = datetime.now()
    new_val = 50 + 20 * np.sin(t) + np.random.randn() * 5

    source.stream({'time': [now], 'value': [new_val],
                   'value2': [new_val * 0.8 + 10]}, MAX_POINTS)

    # 移动平均
    values = source.data['value']
    mean_val = np.mean(values[-min(10, len(values)):])
    means = list(source.data['mean']) + [mean_val]
    source.data['mean'] = means[-MAX_POINTS:]

curdoc().add_root(column(p))
curdoc().add_periodic_callback(update, 500)  # 每 500ms 执行一次
curdoc().title = "实时监控"

注意:此模式需要 Bokeh Server 支持,不能在纯 Notebook 中以 show() 方式运行。


对比分析模式(Comparison)

概念:将两组或多组数据并排或叠加展示,支持交互式选择对比的维度和范围。

适用场景

  • 同比/环比分析(如今年 vs 去年)
  • A/B 测试结果对比

核心代码——关键在于 Select 控件切换数据集 + 差异计算回调:

from bokeh.models import Select

months = ['1月', '2月', '3月', '4月', '5月', '6月',
          '7月', '8月', '9月', '10月', '11月', '12月']

# 多个数据集(通过 Select 切换展示哪个)
datasets = {
    '2023年': np.random.uniform(80, 150, 12).tolist(),
    '2024年': np.random.uniform(100, 180, 12).tolist(),
    '2024年目标': [150] * 12,
}
x_indices = list(range(12))

source_a = ColumnDataSource(data={'x': x_indices, 'y': datasets['2023年']})
source_b = ColumnDataSource(data={'x': x_indices, 'y': datasets['2024年']})
source_diff = ColumnDataSource(data={'x': x_indices, 'y': []})

sel_a = Select(title="数据集 A:", value="2023年", options=list(datasets.keys()))
sel_b = Select(title="数据集 B:", value="2024年", options=list(datasets.keys()))

p = figure(width=700, height=350, title="对比分析", x_range=months)
p.line('x', 'y', source=source_a, line_width=2.5, color='steelblue', legend_label="A")
p.line('x', 'y', source=source_b, line_width=2.5, color='coral', line_dash='dashed', legend_label="B")

p_diff = figure(width=700, height=180, title="差异(B - A)", x_range=months)
p_diff.vbar('x', top='y', source=source_diff, width=0.6, color='gray', alpha=0.7)

# ★ 核心:切换数据集时重新计算差异
compare_callback = CustomJS(
    args=dict(datasets=datasets, source_a=source_a, source_b=source_b,
              source_diff=source_diff, sel_a=sel_a, sel_b=sel_b),
    code="""
    const a = datasets[sel_a.value], b = datasets[sel_b.value];
    source_a.data['y'] = a;
    source_b.data['y'] = b;
    source_diff.data['y'] = a.map((v, i) => b[i] - v);
    source_a.change.emit();
    source_b.change.emit();
    source_diff.change.emit();
    """
)

sel_a.js_on_change('value', compare_callback)
sel_b.js_on_change('value', compare_callback)

show(column(row(sel_a, sel_b), p, p_diff))

模式选择指南

根据实际需求选择合适的交互模式,可以参考以下决策表:

需求描述推荐模式辅助模式Bokeh 关键能力
“选中一个,看到细节”主从联动selected 事件、共享数据源
“一步步缩小范围”过滤钻取Select 控件、CustomJS 过滤
“我想调参数看效果”参数控制SliderTextInput 控件
“数据在实时产生”实时监控periodic_callbackstream()
“对比两组数据的差异”对比分析多数据源叠加、差异计算
“既要概览,又能看细节”主从联动 + 过滤钻取组合使用
“监控的同时可以调参”实时监控 + 参数控制组合使用

模式组合建议

概览型仪表盘 → 主从联动 + 过滤钻取
监控型仪表盘 → 实时监控 + 对比分析
分析型仪表盘 → 参数控制 + 过滤钻取
决策型仪表盘 → 对比分析 + 主从联动

6.10 交互体验最佳实践

优秀的交互不仅在于功能实现,更在于用户体验的细节打磨。本节从六个方面总结交互设计的最佳实践。

响应速度优化

交互的响应速度直接影响用户体验。以下是保持交互流畅的关键策略:

1. 使用 CustomJS 替代 Python 回调(适用于纯数据过滤和样式变更)

from bokeh.models import Slider, CustomJS, ColumnDataSource
from bokeh.plotting import figure, show
from bokeh.io import output_notebook

output_notebook()

source = ColumnDataSource(data={
    'x': list(range(100)),
    'y': [i ** 0.5 for i in range(100)],
})

p = figure(width=600, height=350)
renderer = p.circle('x', 'y', source=source, size=8)

slider = Slider(start=1, end=20, value=8, step=1, title="点大小")

# ✅ 推荐:使用 CustomJS 在前端直接修改 glyph 属性
slider.js_on_change('value', CustomJS(
    args=dict(renderer=renderer),
    code="""
    renderer.glyph.size = {value: cb_obj.value};
    """
))

show(column(slider, p))

2. 数据分片加载

import numpy as np
from bokeh.models import ColumnDataSource

N_TOTAL = 100000
N_DISPLAY = 5000

# 只加载要显示的数据子集
all_data = {
    'x': np.random.randn(N_TOTAL),
    'y': np.random.randn(N_TOTAL),
}

display_indices = np.random.choice(N_TOTAL, N_DISPLAY, replace=False)
display_source = ColumnDataSource(data={
    'x': all_data['x'][display_indices],
    'y': all_data['y'][display_indices],
})

3. 避免不必要的数据重绘

from bokeh.models import CustomJS

# ✅ 推荐:只修改需要变化的属性
update_callback = CustomJS(args=dict(source=source), code="""
    source.data['highlight'] = source.data['value'].map(
        v => v > threshold ? 1 : 0
    );
    source.patch({
        highlight: [/* 只发送变化的部分 */]
    });
""")

# ❌ 不推荐:每次都替换全部数据
# source.data = new_full_data  // 触发完整重绘

响应速度参考标准

操作类型建议响应时间实现策略
悬停提示< 50ms内置 HoverTool
选择高亮< 100msCustomJS + 共享数据源
过滤更新< 300ms前端 CustomJS 过滤
参数调整< 200ms直接修改 glyph 属性
全量刷新< 1s后端计算 + 数据替换

状态反馈

用户执行操作后,应提供清晰的视觉反馈,告知用户操作已被接收和处理。

1. 加载指示器

from bokeh.models import Div, CustomJS

loading_div = Div(text="", width=200, height=30)

loading_callback = CustomJS(
    args=dict(loading_div=loading_div),
    code="""
    loading_div.text = '<span style="color: #666;">⏳ 数据加载中...</span>';
    setTimeout(function() {
        loading_div.text = '<span style="color: green;">✅ 加载完成</span>';
        setTimeout(function() {
            loading_div.text = '';
        }, 3000);
    }, 500);
    """
)

2. 选择状态计数

from bokeh.models import ColumnDataSource, Div, CustomJS

source = ColumnDataSource(data={
    'x': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
    'y': [2, 5, 3, 8, 1, 6, 4, 9, 7, 2],
})

status_div = Div(text="已选中: 0 项", width=200, height=30)

source.selected.js_on_change('indices', CustomJS(
    args=dict(source=source, status_div=status_div),
    code="""
    const count = source.selected.indices.length;
    const total = source.data['x'].length;
    status_div.text = '已选中: ' + count + ' / ' + total + ' 项';
    """
))

3. 操作确认提示

from bokeh.models import Button, Div, CustomJS

confirm_div = Div(text="", width=300, height=30)

reset_button = Button(label="重置筛选", button_type="warning")
reset_button.js_on_click(CustomJS(
    args=dict(confirm_div=confirm_div),
    code="""
    confirm_div.text = '<span style="color: orange;">⚠️ 筛选条件已重置</span>';
    setTimeout(function() { confirm_div.text = ''; }, 2000);
    """
))

渐进式披露

避免一次性展示过多信息,根据用户的操作逐步展示更多细节。

1. 分层信息展示

from bokeh.models import HoverTool, CustomJS, ColumnDataSource, Div
from bokeh.plotting import figure, show
from bokeh.layouts import column
from bokeh.io import output_notebook

output_notebook()

source = ColumnDataSource(data={
    'x': [1, 2, 3, 4, 5],
    'y': [2, 5, 8, 2, 7],
    'name': ['产品A', '产品B', '产品C', '产品D', '产品E'],
    'summary': ['销量: 100', '销量: 200', '销量: 150',
                '销量: 80', '销量: 250'],
    'detail': ['华东区领先,月增5%', '全国热销,库存紧张',
               '新品推广期', '清仓处理中', '年度爆款,同比+30%'],
})

# 第一层:悬停显示摘要
hover = HoverTool(tooltips=[
    ("名称", "@name"),
    ("概要", "@summary"),
])

# 第二层:点击显示完整详情
detail_div = Div(
    text="<i>点击数据点查看详细信息</i>",
    width=400, height=60,
)

p = figure(tools=[hover, "tap", "wheel_zoom", "reset"],
           width=500, height=350)
p.circle('x', 'y', source=source, size=15, color='steelblue')

source.selected.js_on_change('indices', CustomJS(
    args=dict(source=source, detail_div=detail_div),
    code="""
    const indices = source.selected.indices;
    if (indices.length > 0) {
        const idx = indices[0];
        detail_div.text = '<b>' + source.data['name'][idx] + '</b><br>'
            + source.data['summary'][idx] + '<br>'
            + '<span style="color: #666;">'
            + source.data['detail'][idx] + '</span>';
    }
    """
))

show(column(p, detail_div))

2. 可折叠面板

from bokeh.models import CheckboxGroup, CustomJS
from bokeh.layouts import column
from bokeh.io import output_notebook, show

output_notebook()

# 使用 CheckboxGroup 控制信息层的显示
info_toggle = CheckboxGroup(
    labels=["显示数据表格", "显示统计信息", "显示趋势线"],
    active=[0],
)

info_toggle.js_on_change('active', CustomJS(
    args=dict(toggle=info_toggle),
    code="""
    // 根据勾选项控制对应元素的可见性
    const active = toggle.active;
    console.log('当前激活项:', active);
    """
))

show(column(info_toggle))

一致性

在同一仪表盘或应用中保持交互模式的一致性,降低用户学习成本。

一致性检查清单

维度一致性要求示例
颜色语义同一类别始终使用相同颜色销售额始终为蓝色,利润始终为绿色
选择行为选择操作方式统一统一使用点击选择或框选
提示格式HoverTool 格式统一所有图表的提示框使用相同的排版
导航方式缩放和平移行为一致所有图表共享缩放范围或独立缩放明确标注
控件风格控件外观和排列统一所有滑块从左到右排列,标签在上方

统一 HoverTool 格式的示例

from bokeh.models import HoverTool

# 定义统一的提示框模板
def create_standard_hover(extra_fields=None):
    """创建标准格式的 HoverTool"""
    tooltips = [
        ("名称", "@name"),
        ("数值", "@value{0,0.00}"),
        ("占比", "@percent{0.0%}"),
    ]
    if extra_fields:
        tooltips.extend(extra_fields)
    return HoverTool(tooltips=tooltips)

# 所有图表使用相同格式
hover1 = create_standard_hover([("地区", "@region")])
hover2 = create_standard_hover([("时间", "@date{%Y-%m}")])

统一颜色映射的示例

from bokeh.transform import factor_cmap
from bokeh.palettes import Category10

# 定义全局颜色映射
CATEGORY_COLORS = {
    '电子产品': Category10[10][0],
    '服装': Category10[10][1],
    '食品': Category10[10][2],
    '图书': Category10[10][3],
    '家居': Category10[10][4],
}

# 在多个图表中复用同一映射
color_mapper = factor_cmap(
    'category',
    palette=list(CATEGORY_COLORS.values()),
    factors=list(CATEGORY_COLORS.keys()),
)

交互复杂度控制

并非所有场景都需要复杂的交互。根据用户技术水平和使用频率选择合适的交互复杂度。

复杂度分级

级别交互形式适用场景用户要求
L0 - 静态无交互,仅展示报告、截图导出
L1 - 浏览悬停、缩放、平移日常查看、演示基础
L2 - 选择点选、框选、联动数据探索中等
L3 - 控制滑块、过滤器、参数深度分析较高
L4 - 编辑绘制、标注、修改数据标注、设计专业

建议

  • 面向非技术用户:最高到 L1-L2
  • 面向分析师:L2-L3
  • 面向数据科学家:L3-L4
  • 演示场景:使用 L0-L1,确保稳定性

移动端适配

移动设备上的交互需要特别考虑触摸操作和屏幕尺寸。

1. 触摸友好的交互元素

from bokeh.plotting import figure
from bokeh.models import ColumnDataSource

source = ColumnDataSource(data={'x': [1, 2, 3], 'y': [4, 5, 6]})

# 移动端适配:增大点击区域
p = figure(
    width=400, height=350,
    tools="tap,pan,wheel_zoom",
    sizing_mode="stretch_width",  # 自适应宽度
)

# 使用较大的数据点和字体
p.circle('x', 'y', source=source, size=25, alpha=0.7)  # 更大的点击目标
p.title.text_font_size = "16pt"
p.axis.axis_label_text_font_size = "12pt"
p.axis.major_label_text_font_size = "10pt"

2. 响应式布局

from bokeh.layouts import gridplot
from bokeh.models import Div

# 使用 sizing_mode 实现响应式
title = Div(
    text="<h2>响应式仪表盘</h2>",
    sizing_mode="stretch_width",
)
p1 = figure(width=400, height=300, sizing_mode="scale_width")
p2 = figure(width=400, height=300, sizing_mode="scale_width")

# gridplot 支持自动换行
grid = gridplot(
    [[p1, p2]],
    sizing_mode="stretch_width",
)

# 移动端提示
mobile_hint = Div(
    text="<small>📱 双指缩放 | 单指平移 | 点击选择</small>",
    sizing_mode="stretch_width",
)

3. 简化移动端工具栏

from bokeh.plotting import figure

# 移动端精简工具
p_mobile = figure(
    tools="tap,pan,wheel_zoom,reset",  # 只保留核心工具
    toolbar_location="below",  # 工具栏放底部,方便拇指操作
)

# 隐藏不常用的工具
p_mobile.toolbar.logo = None  # 移除 logo 节省空间

6.11 无障碍访问指导

数据可视化应尽可能让所有人都能使用,包括视觉障碍、色觉缺陷和运动障碍的用户。本节介绍如何在 Bokeh 中实现无障碍访问支持。

键盘导航

确保图表可以通过键盘进行基本操作,这对于无法使用鼠标的用户至关重要。

实现思路

Bokeh 的图表渲染在 HTML Canvas 上,Canvas 本身不支持键盘焦点导航。因此需要通过额外的 HTML 元素和事件监听来补充键盘交互能力。

from bokeh.models import CustomJS, ColumnDataSource, Div, Button
from bokeh.plotting import figure, show
from bokeh.layouts import column
from bokeh.io import output_notebook

output_notebook()

source = ColumnDataSource(data={
    'x': [1, 2, 3, 4, 5],
    'y': [2, 5, 8, 2, 7],
    'name': ['产品A', '产品B', '产品C', '产品D', '产品E'],
    'value': [100, 200, 150, 80, 250],
})

p = figure(width=600, height=400, title="键盘导航示例")
renderers = p.circle('x', 'y', source=source, size=15, color='steelblue')

# 键盘焦点信息面板
focus_info = Div(
    text="<i>使用下方按钮或键盘方向键浏览数据点</i>",
    width=400, height=40,
)

# 当前焦点索引(通过 Div 的 text 属性在 JS 中传递状态)
focus_state = Div(text="0", visible=False)

# 上一个/下一个按钮
prev_btn = Button(label="← 上一个", button_type="primary")
next_btn = Button(label="下一个 →", button_type="primary")

prev_btn.js_on_click(CustomJS(
    args=dict(source=source, focus_info=focus_info, focus_state=focus_state),
    code="""
    let idx = parseInt(focus_state.text) || 0;
    idx = (idx - 1 + source.data['x'].length) % source.data['x'].length;
    focus_state.text = String(idx);
    focus_info.text = '<b>' + source.data['name'][idx] + '</b>: '
        + '值 = ' + source.data['value'][idx];

    // 高亮当前数据点
    const n = source.data['x'].length;
    const colors = [];
    const sizes = [];
    for (let i = 0; i < n; i++) {
        colors.push(i === idx ? 'red' : 'steelblue');
        sizes.push(i === idx ? 25 : 15);
    }
    source.data['_focus_color'] = colors;
    source.data['_focus_size'] = sizes;
    source.change.emit();
    """
))

next_btn.js_on_click(CustomJS(
    args=dict(source=source, focus_info=focus_info, focus_state=focus_state),
    code="""
    let idx = parseInt(focus_state.text) || 0;
    idx = (idx + 1) % source.data['x'].length;
    focus_state.text = String(idx);
    focus_info.text = '<b>' + source.data['name'][idx] + '</b>: '
        + '值 = ' + source.data['value'][idx];

    // 高亮当前数据点
    const n = source.data['x'].length;
    const colors = [];
    const sizes = [];
    for (let i = 0; i < n; i++) {
        colors.push(i === idx ? 'red' : 'steelblue');
        sizes.push(i === idx ? 25 : 15);
    }
    source.data['_focus_color'] = colors;
    source.data['_focus_size'] = sizes;
    source.change.emit();
    """
))

show(column(focus_info, prev_btn, next_btn, p))

键盘导航最佳实践

  • 提供明确的焦点指示(高亮当前项)
  • 支持 Tab 键在控件间切换
  • 使用方向键在数据点间移动
  • Enter/Space 触发选择操作
  • Escape 取消或返回上级

色盲友好配色

约 8% 的男性和 0.5% 的女性存在色觉缺陷。使用色盲友好的配色方案是可视化无障碍的基本要求。

Bokeh 内置的色盲友好调色板

from bokeh.palettes import colorblind
from bokeh.plotting import figure, show
from bokeh.models import ColumnDataSource
from bokeh.io import output_notebook

output_notebook()

# Bokeh 提供了专门的色盲友好调色板
# colorblind 调色板: ['Colorblind'][8]
print("色盲友好调色板:", colorblind['Colorblind'][8])

# 使用色盲友好调色板
categories = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
values = [25, 40, 30, 55, 35, 45, 20, 50]

source = ColumnDataSource(data={
    'x': categories,
    'top': values,
    'color': colorblind['Colorblind'][8],
})

p = figure(width=600, height=400, title="色盲友好配色示例",
           x_range=categories, tools="hover")
p.vbar(x='x', top='top', source=source, width=0.6,
       color='color', alpha=0.8)

show(p)

推荐的色盲友好配色策略

from bokeh.palettes import Viridis256, Cividis256, colorblind
import colorsys

# 策略1:使用感知均匀的连续调色板
# Viridis 和 Cividis 对色觉缺陷用户友好
sequential_palette = Viridis256  # 推荐用于连续数据
diverging_safe = Cividis256      # 推荐用于所有类型色觉缺陷

# 策略2:使用 Bokeh 内置的色盲友好分类调色板
categorical_safe = colorblind['Colorblind'][8]

# 策略3:自定义色盲友好配色
# 以下配色经过验证,在三种主要色觉缺陷下均可区分
COLORBLIND_SAFE_6 = [
    '#0072B2',  # 蓝色
    '#E69F00',  # 橙色
    '#009E73',  # 绿色
    '#D55E00',  # 朱红色
    '#CC79A7',  # 粉紫色
    '#56B4E9',  # 天蓝色
    '#F0E442',  # 黄色
    '#000000',  # 黑色
]

# 策略4:除了颜色,额外使用形状或图案区分
# 在散点图中同时使用颜色和标记形状
from bokeh.plotting import figure
from bokeh.models import ColumnDataSource

markers = ['circle', 'square', 'triangle', 'diamond']

验证配色方案的色盲安全性

from bokeh.palettes import Category10, colorblind
from bokeh.plotting import figure, show
from bokeh.layouts import row
from bokeh.models import ColumnDataSource
from bokeh.io import output_notebook

output_notebook()

# 对比展示:普通调色板 vs 色盲友好调色板
categories = ['类别A', '类别B', '类别C', '类别D']
values = [30, 50, 20, 40]

# 普通调色板
source1 = ColumnDataSource(data={
    'x': categories, 'top': values,
    'color': Category10[4],
})

# 色盲友好调色板
source2 = ColumnDataSource(data={
    'x': categories, 'top': values,
    'color': colorblind['Colorblind'][4],
})

p1 = figure(width=350, height=300, title="普通调色板",
            x_range=categories, tools="hover")
p1.vbar(x='x', top='top', source=source1, width=0.6, color='color')

p2 = figure(width=350, height=300, title="色盲友好调色板",
            x_range=categories, tools="hover")
p2.vbar(x='x', top='top', source=source2, width=0.6, color='color')

show(row(p1, p2))

屏幕阅读器支持

为图表添加 ARIA 标签和描述,使屏幕阅读器能够向用户传达图表内容。

1. 使用 Div 添加图表描述

from bokeh.models import Div, ColumnDataSource
from bokeh.plotting import figure, show
from bokeh.layouts import column
from bokeh.io import output_notebook

output_notebook()

source = ColumnDataSource(data={
    'x': [1, 2, 3, 4, 5],
    'y': [2, 5, 8, 2, 7],
    'name': ['产品A', '产品B', '产品C', '产品D', '产品E'],
})

p = figure(width=600, height=400, title="月度销售趋势")
p.line('x', 'y', source=source, line_width=2, color='steelblue')
p.circle('x', 'y', source=source, size=10, color='steelblue')

# 为屏幕阅读器提供详细的图表描述
chart_description = Div(text="""
<div role="img" aria-label="月度销售趋势折线图"
     aria-describedby="chart-desc">
    <p id="chart-desc" class="sr-only">
        折线图显示5个产品的月度销售趋势。
        X轴为产品编号(产品A到产品E),
        Y轴为销售额。
        产品C的销售额最高(8),
        产品D的销售额最低(2)。
        整体趋势为先升后降再回升。
    </p>
</div>
""", visible=True)

# 辅助样式:屏幕阅读器可见但视觉隐藏
sr_style = Div(text="""
<style>
    .sr-only {
        position: absolute;
        width: 1px;
        height: 1px;
        padding: 0;
        margin: -1px;
        overflow: hidden;
        clip: rect(0, 0, 0, 0);
        white-space: nowrap;
        border: 0;
    }
</style>
""")

show(column(sr_style, chart_description, p))

2. 动态更新 ARIA 描述

from bokeh.models import CustomJS, Div

# 当数据更新时,同步更新图表的文字描述
aria_div = Div(text="", width=600, height=30)

update_aria = CustomJS(
    args=dict(source=source, aria_div=aria_div),
    code="""
    const x = source.data['x'];
    const y = source.data['y'];
    const max_idx = y.indexOf(Math.max(...y));
    const min_idx = y.indexOf(Math.min(...y));

    aria_div.text = '图表概要: 共 ' + x.length + ' 个数据点, '
        + '最大值 ' + Math.max(...y).toFixed(1)
        + ' (第' + (max_idx + 1) + '项), '
        + '最小值 ' + Math.min(...y).toFixed(1)
        + ' (第' + (min_idx + 1) + '项).';
    """
)

source.js_on_change('data', update_aria)

替代文本

为图表提供文字替代方案,当图表无法显示或用户使用辅助技术时,仍然可以获取数据信息。

1. 基础替代文本

from bokeh.models import Div, ColumnDataSource, DataTable, TableColumn
from bokeh.plotting import figure, show
from bokeh.layouts import column, tabs
from bokeh.io import output_notebook

output_notebook()

source = ColumnDataSource(data={
    'category': ['A', 'B', 'C', 'D', 'E'],
    'value': [25, 40, 30, 55, 35],
    'description': [
        '市场份额25%,同比增长5%',
        '市场份额40%,同比下降2%',
        '市场份额30%,同比增长8%',
        '市场份额55%,同比增长12%',
        '市场份额35%,同比持平',
    ],
})

# 图表视图
p = figure(width=500, height=350, title="市场份额分布",
           x_range=source.data['category'])
p.vbar(x='category', top='value', source=source, width=0.5,
       color='steelblue', alpha=0.7)

# 文字替代:数据表格
columns = [
    TableColumn(field="category", title="类别"),
    TableColumn(field="value", title="数值"),
    TableColumn(field="description", title="说明"),
]
data_table = DataTable(source=source, columns=columns,
                       width=500, height=200)

# 文字摘要
summary = Div(text="""
<h3>数据摘要</h3>
<p>本图表展示5个类别的市场份额分布。</p>
<ul>
<li>最高:类别D(55%)</li>
<li>最低:类别A(25%)</li>
<li>平均值:37%</li>
</ul>
<p>总体趋势:类别D领先,类别A和B相对较低。</p>
""", width=500)

show(column(p, data_table, summary))

2. 自动生成数据摘要

from bokeh.models import CustomJS, Div

def create_data_summary_div(source, x_field, y_field, title="图表摘要"):
    """创建自动更新的数据摘要 Div"""
    return Div(text="", width=600, height=80)

summary_div = create_data_summary_div(source, 'category', 'value')

# 使用 CustomJS 自动计算并展示摘要
summary_callback = CustomJS(
    args=dict(source=source, summary_div=summary_div, title="市场份额分布"),
    code="""
    const values = source.data['value'];
    const categories = source.data['category'];

    const max_val = Math.max(...values);
    const min_val = Math.min(...values);
    const avg_val = values.reduce((a, b) => a + b, 0) / values.length;
    const max_idx = values.indexOf(max_val);
    const min_idx = values.indexOf(min_val);

    summary_div.text = '<strong>' + title + ' 数据摘要:</strong>'
        + '共 ' + values.length + ' 个类别,'
        + '最高为 ' + categories[max_idx] + '(' + max_val.toFixed(1) + '),'
        + '最低为 ' + categories[min_idx] + '(' + min_val.toFixed(1) + '),'
        + '平均值为 ' + avg_val.toFixed(1) + '。';
    """
)

source.js_on_change('data', summary_callback)

高对比度模式

为需要高对比度的用户提供增强的视觉方案,确保在低视力或强光环境下仍然可读。

1. 高对比度配色方案

from bokeh.plotting import figure, show
from bokeh.models import ColumnDataSource
from bokeh.io import output_notebook

output_notebook()

# 高对比度配色
HIGH_CONTRAST = {
    'background': '#000000',       # 纯黑背景
    'foreground': '#FFFFFF',       # 纯白前景
    'colors': [
        '#FFFFFF',  # 白色
        '#FFFF00',  # 黄色
        '#00FFFF',  # 青色
        '#FF00FF',  # 品红
        '#00FF00',  # 绿色
        '#FF6600',  # 橙色
    ],
    'grid': '#444444',
    'axis': '#CCCCCC',
}

source = ColumnDataSource(data={
    'x': [1, 2, 3, 4, 5],
    'y': [2, 5, 8, 2, 7],
})

p = figure(
    width=600, height=400,
    title="高对比度模式",
    background_fill_color=HIGH_CONTRAST['background'],
    border_fill_color=HIGH_CONTRAST['background'],
)

# 设置高对比度样式
p.title.text_color = HIGH_CONTRAST['foreground']
p.title.text_font_size = "16pt"
p.xaxis.axis_line_color = HIGH_CONTRAST['axis']
p.yaxis.axis_line_color = HIGH_CONTRAST['axis']
p.xaxis.major_label_text_color = HIGH_CONTRAST['foreground']
p.yaxis.major_label_text_color = HIGH_CONTRAST['foreground']
p.xaxis.axis_label_text_color = HIGH_CONTRAST['foreground']
p.yaxis.axis_label_text_color = HIGH_CONTRAST['foreground']
p.xgrid.grid_line_color = HIGH_CONTRAST['grid']
p.ygrid.grid_line_color = HIGH_CONTRAST['grid']
p.outline_line_color = HIGH_CONTRAST['axis']

# 使用明亮颜色的数据点
p.circle('x', 'y', source=source, size=15,
         color=HIGH_CONTRAST['colors'][0],
         line_color=HIGH_CONTRAST['colors'][1],
         line_width=2)
p.line('x', 'y', source=source, line_width=3,
       color=HIGH_CONTRAST['colors'][1])

show(p)

2. 通过控件切换对比度模式

from bokeh.models import Select, CustomJS, ColumnDataSource
from bokeh.plotting import figure, show
from bokeh.layouts import column
from bokeh.io import output_notebook

output_notebook()

source = ColumnDataSource(data={
    'x': [1, 2, 3, 4, 5],
    'y': [2, 5, 8, 2, 7],
})

p = figure(width=600, height=400, title="可切换对比度模式")
renderer = p.circle('x', 'y', source=source, size=15,
                    color='steelblue', line_color='white', line_width=1)
line = p.line('x', 'y', source=source, line_width=2, color='steelblue')

# 对比度模式选择
contrast_select = Select(
    title="显示模式:",
    value="normal",
    options=[
        ("normal", "标准模式"),
        ("high_contrast", "高对比度"),
        ("dark", "深色模式"),
    ],
)

contrast_select.js_on_change('value', CustomJS(
    args=dict(p=p, renderer=renderer, line=line),
    code="""
    const mode = cb_obj.value;

    if (mode === 'high_contrast') {
        p.background_fill_color = '#000000';
        p.border_fill_color = '#000000';
        p.title.text_color = '#FFFFFF';
        p.xaxis.major_label_text_color = '#FFFFFF';
        p.yaxis.major_label_text_color = '#FFFFFF';
        p.xaxis.axis_line_color = '#CCCCCC';
        p.yaxis.axis_line_color = '#CCCCCC';
        p.xgrid.grid_line_color = '#444444';
        p.ygrid.grid_line_color = '#444444';
        p.outline_line_color = '#CCCCCC';
        renderer.glyph.fill_color = {value: '#FFFF00'};
        renderer.glyph.line_color = {value: '#FFFFFF'};
        line.glyph.line_color = {value: '#FFFF00'};
    } else if (mode === 'dark') {
        p.background_fill_color = '#1a1a2e';
        p.border_fill_color = '#16213e';
        p.title.text_color = '#e0e0e0';
        p.xaxis.major_label_text_color = '#cccccc';
        p.yaxis.major_label_text_color = '#cccccc';
        p.xaxis.axis_line_color = '#555555';
        p.yaxis.axis_line_color = '#555555';
        p.xgrid.grid_line_color = '#333333';
        p.ygrid.grid_line_color = '#333333';
        p.outline_line_color = '#555555';
        renderer.glyph.fill_color = {value: '#00d4ff'};
        renderer.glyph.line_color = {value: '#e0e0e0'};
        line.glyph.line_color = {value: '#00d4ff'};
    } else {
        p.background_fill_color = '#ffffff';
        p.border_fill_color = '#ffffff';
        p.title.text_color = '#333333';
        p.xaxis.major_label_text_color = '#666666';
        p.yaxis.major_label_text_color = '#666666';
        p.xaxis.axis_line_color = '#cccccc';
        p.yaxis.axis_line_color = '#cccccc';
        p.xgrid.grid_line_color = '#eeeeee';
        p.ygrid.grid_line_color = '#eeeeee';
        p.outline_line_color = '#cccccc';
        renderer.glyph.fill_color = {value: 'steelblue'};
        renderer.glyph.line_color = {value: 'white'};
        line.glyph.line_color = {value: 'steelblue'};
    }
    p.change.emit();
    """
))

show(column(contrast_select, p))

高对比度设计要点

要素标准模式高对比度模式WCAG AA 标准
正文文字对比度4.5:17:1+≥ 4.5:1
大文字对比度3:14.5:1+≥ 3:1
数据点与背景3:14.5:1+≥ 3:1
网格线可见性低对比中等对比不强制
交互元素边框可选必须明显≥ 3:1

WCAG(Web Content Accessibility Guidelines) 是 Web 无障碍的国际标准。上述对比度要求来自 WCAG 2.1 AA 级别。对于数据可视化,建议至少满足 AA 级别。


6.11 本章小结

本章你已经接触了 Bokeh 中最核心的一组交互能力:

  • 使用 HoverTool 提供悬停提示
  • 通过共享 ColumnDataSource 实现链接刷选和多图联动
  • 使用 CustomJS 在浏览器端实现轻量交互
  • 理解了 Python 回调与 Bokeh Server 的关系
  • 看到了主从联动、过滤钻取、参数控制、实时监控、对比分析等常见交互模式

如果你发现自己已经不满足于“浏览器端小交互”,而是需要:

  • 访问数据库或文件系统
  • 执行真正的 Python 计算
  • 做周期性刷新
  • 管理多用户会话状态

那么下一步就应该进入第七章:Bokeh Server 进阶

与此同时,如果你在实现交互时遇到下面几类问题,也可以回看对应章节:

  • 数据结构不清楚:回看第二章
  • 多图排布混乱:回看第五章
  • 需要导出 HTML / Notebook / Web 嵌入:回看第八章
  • 交互变卡、数据量太大:回看第九章

6.12 常见坑

坑 1:把 Python 回调当成 standalone HTML 的能力

这是最常见的误解。on_change()on_event() 这类 Python 回调只有在 Bokeh Server 环境里才会执行。
如果你是用 show()save() 导出单个 HTML 文件,那么应该优先使用:

  • CustomJS
  • js_on_change()
  • js_on_event()

坑 2:修改了 source.data,却没有触发前端更新

在 Python 端,给 source.data = {...} 整体赋值通常会触发更新。
但在 JavaScript 端,如果你是直接修改已有数组内容,通常还需要:

  • source.change.emit()

如果你对 ColumnDataSource 的结构和更新语义还不熟,建议回看第二章 2.3

坑 3:联动没生效,其实是因为没有共享同一个数据源

很多“链接刷选失败”的根本原因不是工具配置错了,而是两个图并没有真正共享同一个 ColumnDataSource
只要数据源不同:

  • 选择状态不会同步
  • 高亮不会自动联动
  • selected.indices 也不会共享

坑 4:交互写得太复杂,却没有先区分运行模式

在开始写交互前,最好先回答一个问题:

你要的是 standalone 文档,还是 Bokeh Server 应用?

经验上可以这样判断:

  • 只是 hover、筛选、前端联动:优先 CustomJS
  • 需要 Python 计算、周期更新、服务端状态:使用 Bokeh Server

这个判断会直接影响你后面选什么 API,也能避免大量返工。

坑 5:把所有交互都塞进一个页面

交互越多,不一定越好。
如果一个页面同时有太多:

  • 滑块
  • 下拉框
  • 图例控制
  • 过滤器
  • 多图联动
  • 实时刷新

用户反而会更难理解。更稳妥的做法通常是:

  1. 先保留最核心的一个交互路径
  2. 再逐步增加辅助控件
  3. 必要时拆成多个视图或标签页

延伸阅读:本章的 Python 回调(6.7 节中的 on_change)需要 Bokeh Server 支持,详见第七章。交互功能的性能优化技巧见第九章;如果你需要把交互结果输出到 Notebook、HTML 或网页中,请继续阅读第八章