第六章:交互功能场景实战
前置知识:本章需要掌握 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_change、on_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 Server(curdoc()),纯前端 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 过滤 |
| “我想调参数看效果” | 参数控制 | — | Slider、TextInput 控件 |
| “数据在实时产生” | 实时监控 | — | periodic_callback、stream() |
| “对比两组数据的差异” | 对比分析 | — | 多数据源叠加、差异计算 |
| “既要概览,又能看细节” | 主从联动 + 过滤钻取 | — | 组合使用 |
| “监控的同时可以调参” | 实时监控 + 参数控制 | — | 组合使用 |
模式组合建议:
概览型仪表盘 → 主从联动 + 过滤钻取
监控型仪表盘 → 实时监控 + 对比分析
分析型仪表盘 → 参数控制 + 过滤钻取
决策型仪表盘 → 对比分析 + 主从联动
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 |
| 选择高亮 | < 100ms | CustomJS + 共享数据源 |
| 过滤更新 | < 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:1 | 7:1+ | ≥ 4.5:1 |
| 大文字对比度 | 3:1 | 4.5:1+ | ≥ 3:1 |
| 数据点与背景 | 3:1 | 4.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 进阶。
与此同时,如果你在实现交互时遇到下面几类问题,也可以回看对应章节:
6.12 常见坑
坑 1:把 Python 回调当成 standalone HTML 的能力
这是最常见的误解。on_change()、on_event() 这类 Python 回调只有在 Bokeh Server 环境里才会执行。
如果你是用 show()、save() 导出单个 HTML 文件,那么应该优先使用:
CustomJSjs_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:把所有交互都塞进一个页面
交互越多,不一定越好。
如果一个页面同时有太多:
- 滑块
- 下拉框
- 图例控制
- 过滤器
- 多图联动
- 实时刷新
用户反而会更难理解。更稳妥的做法通常是:
- 先保留最核心的一个交互路径
- 再逐步增加辅助控件
- 必要时拆成多个视图或标签页
延伸阅读:本章的 Python 回调(6.7 节中的
on_change)需要 Bokeh Server 支持,详见第七章。交互功能的性能优化技巧见第九章;如果你需要把交互结果输出到 Notebook、HTML 或网页中,请继续阅读第八章。